1. How a loop works: start, boundary, step
When we write a loop, the brain usually thinks about the “big idea” — repeat an action n times or until a condition is met. But the computer is very literal: it needs precise rules for where to start, when to stop, and how to move. Almost all loop mistakes boil down to the fact that one of the three is defined incorrectly: start, boundary, or step.
It’s convenient to keep a simple table in mind (basically the “spec sheet” of any loop):
| Loop part | What it means in plain language | Example | Common mistake |
|---|---|---|---|
| Start | “What value do we start from?” | i := 0 or i := 1 | started in the wrong place and got an extra/missing iteration |
| Boundary | “When should we stop?” | i < n or i <= n | mixed up < and <= → off-by-one |
| Step | “How does the state change?” | i++, i--, x = x * 2 | forgot the update or update “in the wrong direction” → infinite loop |
To avoid looking like someone who’s hunting a bug in “compiler magic,” it helps to build a habit: when you see a loop, mentally ask yourself “start? boundary? step?”. It’s like checking whether you locked the door — boring, but it saves your nerves.
2. Off-by-one: one iteration too many or too few
Off-by-one is an error where a loop runs one iteration more or one iteration less than it should. It doesn’t sound scary, but in practice it’s exactly the kind of bug that ruins sums, skips the last character of a string, breaks range searches, and makes you suspect a universal conspiracy. The usual cause is confusion between < and <=, or an incorrect start (0 instead of 1, or vice versa).
The most honest example: printing 0..4
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
fmt.Println(i) // 0 1 2 3 4
}
}
Here everything is “classically correct”: start 0, boundary i < 5, step i++. It’s important to be able to say out loud: “the last value of i will be 4, because at 5 the condition becomes false.”
Now one small change — and hello, off-by-one:
package main
import "fmt"
func main() {
for i := 0; i <= 5; i++ {
fmt.Println(i) // 0 1 2 3 4 5 (extra iteration!)
}
}
If you wanted exactly 5 numbers (like in the first example), then <= suddenly prints 6 numbers. The computer didn’t make a mistake. We made a mistake in defining the boundary.
Ranges “up to N” vs “including N”: agree in advance
The most common reason beginners get off-by-one is the lack of an agreement: is N included or not?
There are two popular “contracts,” and both are fine. The main thing is not to mix them within one task.
| What we want | Human range | Common loop |
|---|---|---|
| “exactly N times” | |
|
| “from 1 to N inclusive” | |
|
And here comes the psychological trap: “I want up to N, so that means <= N.” But if you started from zero, then <= N gives you N+1 iterations.
Example: sum 1..n
Suppose our program reads n and computes the sum 1 + 2 + ... + n. In this task, off-by-one is visible under a microscope.
Correct (inclusive boundary, start from 1):
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
sum := 0
for i := 1; i <= n; i++ {
sum = sum + i
}
fmt.Println(sum)
}
Typical mistake (start at 0, but the boundary is for range 1..n):
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
sum := 0
for i := 0; i <= n; i++ {
sum = sum + i // added an extra 0? not scary, but the logic “floats”
}
fmt.Println(sum)
}
In this case the sum often “accidentally” turns out correct (because +0 doesn’t change it), and that makes the bug especially sneaky: you get used to incorrect boundaries, and later a similar loop starts breaking a completely different task where the “extra zero” is no longer zero.
3. Boundaries and indexes: len, ranges, and end of a string
After off-by-one, the second big category of problems is when the boundary seems “correct,” but you use it in the wrong place or for the wrong kind of range. Most often this shows up when working with string length (len) and indexes, especially when you manually control the index in a classic for. The key thing to remember: if the length is len(s), then the last valid index is len(s)-1.
Iterating over string indexes via len(s): why <, not <=
package main
import "fmt"
func main() {
s := "hello"
for i := 0; i < len(s); i++ {
fmt.Println(i) // 0 1 2 3 4
}
}
Why is the condition strict (i < len(s))? Because if len(s) == 5, then the indexes are 0..4. Index 5 does not exist.
The mistake looks harmless:
package main
import "fmt"
func main() {
s := "hello"
for i := 0; i <= len(s); i++ {
fmt.Println(i) // 0 1 2 3 4 5 (and 5 is already “beyond meaning”)
}
}
In this example, we just print i, and the program doesn’t crash. But if you were to access s[i] inside the loop, it would end with a runtime panic (and confusion like “why did it even crash, I almost reached the end”).
When boundaries are “mixed up”: the condition is never true
Sometimes a loop “doesn’t work” not because it’s infinite, but because it doesn’t run a single iteration. The reason is that the start and the boundary are incompatible.
package main
import "fmt"
func main() {
n := 5
for i := 10; i < n; i++ {
fmt.Println(i) // nothing will be printed
}
}
The computer honestly checks the condition before the first iteration: 10 < 5 is false, so the loop ends. That’s not a compilation error; it’s a logical error (and sometimes it’s fully expected). So it’s useful to explicitly ask: “is the condition true at least once?”.
The boundary must “move closer”
The boundary must be such that the step makes it closer and closer to stopping. If the condition is i < n, the step is usually i++. If the condition is i > 0, the step is usually i--. If you mix them up, the loop either won’t start or will run forever.
4. Accidental infinite loops: 3 common causes
An infinite loop is not always for {}. Sometimes you write a “normal” for i := ..., and it still “sticks.” This is especially annoying because the loop looks trustworthy: “it’s all by the book.” In practice, infinity appears when the condition variable doesn’t change, changes in the wrong direction, or changes, but you yourself skipped the change with continue.
By the way, Go’s standard library has many APIs where the loop conceptually looks like “while reading works — read; then separately check the error.” This emphasizes the idea that a loop condition should be very honest and controllable.
Forgot to update the state
package main
import "fmt"
func main() {
x := 3
for x > 0 {
fmt.Println(x) // 3 3 3 3 3 ... (and so on forever)
// forgot x--
}
}
From Go’s point of view, everything is logical: x was 3 and stays 3. The condition x > 0 is always true. The loop is eternal.
Correct version:
package main
import "fmt"
func main() {
x := 3
for x > 0 {
fmt.Println(x) // 3 2 1
x--
}
}
Step “in the wrong direction”
package main
import "fmt"
func main() {
for i := 0; i < 5; i-- {
fmt.Println(i) // 0 -1 -2 -3 ... condition i < 5 is always true
}
}
We start at 0, the condition i < 5 is true, and then we do i--, i.e., we go negative. But for negative numbers, i < 5 is also true. We’re not moving toward stopping; we’re riding away from it on a bike with no brakes.
continue ate the update
This one is subtler. In a “while-like” loop (for cond { ... }), the update is usually inside the body. And if you place continue before the update, you can accidentally cancel progress.
Bad example:
package main
import "fmt"
func main() {
i := 0
for i < 5 {
if i == 2 {
continue // i does not change => i is forever 2
}
i++
fmt.Println(i)
}
}
The correct approach is to update before the potential continue (or in a way that it always executes):
package main
import "fmt"
func main() {
i := 0
for i < 5 {
i++ // state update always executes
if i == 2 {
continue
}
fmt.Println(i) // 1 3 4 5
}
}
Note: we didn’t “fix continue”; we fixed the loop architecture — now progress does not depend on which branch of if triggered.
5. Self-checking a loop: 3 iterations and tracing
When a loop behaves strangely, you want to “stare hard” at the code and wait for enlightenment. Usually enlightenment doesn’t come; coffee comes instead, then more coffee, and then fmt.Println("AAAA"). Let’s legalize this: tracing is a normal way to understand a loop. The key is to do it carefully and consciously so you don’t drown in output.
Value table: a calm way to catch off-by-one
Pick 2–3 iterations and write down variable values. For example, the loop:
for i := 1; i <= 3; i++ { ... }
The trace table will look like this:
| Check i <= 3 | i before the body | Does the body run? | i after i++ |
|---|---|---|---|
|
1 | yes | 2 |
|
2 | yes | 3 |
|
3 | yes | 4 |
|
4 | no | — |
You can immediately see: the last iteration was when i == 3.
If you make the same table for i < 3, you will immediately see that i == 3 will no longer happen.
Safety fuse against accidental infinity
Sometimes you suspect a loop might be infinite, but you’re not sure. You can temporarily add an “iteration limiter” so the program won’t hang permanently and will give you a chance to look at the output.
package main
import "fmt"
func main() {
i := 0
limit := 20
for i < 5 {
i++
fmt.Println("i =", i) // i = 1, i = 2, ...
limit--
if limit == 0 {
fmt.Println("stop: safety limit reached")
break
}
}
}
This is not “correct program logic”; it’s a diagnostic tool. But in learning tasks and during debugging it really saves time: instead of an endless hang, you’ll see that i doesn’t change or changes incorrectly.
Checking break reachability
In infinite loops for {} we almost always do a break under some condition. A typical mistake is that the condition exists, but with real data it never happens, and the loop is still infinite.
For example, if you expect input 0 as “stop,” but the input never contains 0 (or you’re reading the wrong thing, or you’re skipping input), break becomes theoretical.
6. Mini-example: reading N numbers without boundary mistakes
Now we’ll assemble a small fragment of our “shared app of the day” — a console mini-utility that reads a number n and then reads exactly n integers. We don’t care about statistics (that’ll be the next lecture); we care specifically about correct boundaries and loop robustness.
The correct “exactly N times” loop
package main
import "fmt"
func main() {
var n int
_, err := fmt.Scan(&n)
if err != nil {
return
}
for i := 0; i < n; i++ {
var x int
fmt.Scan(&x)
fmt.Println("read:", x) // read: <number>
}
}
What matters here is that the loop runs over the range 0..n-1, i.e., exactly n iterations. If out of habit you write i <= n, you will try to read n+1 numbers. If the input is short, the reads will start “falling through” into an error, and you’ll think “Scan broke,” when in fact you broke the boundary.
Guard clause for weird values of n
If the user entered n <= 0, we have two options: do zero iterations (and that’s fine), or explicitly stop. For beginners it’s often easier to stop explicitly so you don’t wonder “why is nothing happening?”
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
if n <= 0 {
fmt.Println("n must be positive") // n must be positive
return
}
for i := 0; i < n; i++ {
fmt.Println("ok") // ok (n times)
}
}
This kind of early return helps avoid “empty loops,” which sometimes look like a bug even though their behavior is formally correct.
An infinite read loop “until zero”: exit points must be clear
Sometimes the input is not “first n, then n numbers,” but “read numbers until you see 0.” That’s an infinite loop for {} with a controlled exit.
package main
import "fmt"
func main() {
for {
var x int
_, err := fmt.Scan(&x)
if err != nil {
break
}
if x == 0 {
break
}
fmt.Println(x) // print everything except 0
}
}
This pattern is good because the exit points (break) are close to each other and read like a sentence: “if input error — stop; if 0 came — stop; otherwise keep working.” And yes, Go generally likes loops of the form “work while it works, then check what went wrong” — that fits well with the overall idea of “errors are values.”
7. Typical loop mistakes
Loops most often break not because of “language complexity,” but because of a microscopic inaccuracy in logic. So it’s useful to have a set of “sentinel thoughts”: what to check first when a loop does extra work, does nothing, or hangs. Below are the most common pitfalls for today’s topics: boundaries, off-by-one, and infinity.
Mistake No. 1: mixing up < and <= without saying the range out loud.
If you can’t say out loud what values the counter will take (for example: “0, 1, 2, 3, 4”), then you are almost guaranteed to write the wrong boundary at least once. The habit of “naming the last value of i” often saves you faster than any tests.
Mistake No. 2: mixing two different contracts 0..n-1 and 1..n.
Both variants are acceptable, but if you started from 0, the boundary is usually strict (< n). If you started from 1, the boundary is usually inclusive (<= n). When these pairs break apart, you get extra iterations, extra reads from stdin, and unexpected results.
Mistake No. 3: forgetting to update the state in for cond { ... }.
A “while-like” loop is nice because the business boundary is visible in the header (x < 100), but it’s also dangerous: the variable update (x = x * 2) lives in the body, and it’s easy to accidentally delete it, move it under a condition, or skip it via an early continue. As a result, the loop stops moving toward termination.
Mistake No. 4: stepping in the opposite direction relative to the condition.
If the condition is i < n, but you do i--, you are almost certainly creating a perpetual motion machine. The condition must become false, and the step must push it in that direction. If the step and the condition “argue,” infinity wins.
Mistake No. 5: continue comes before the variable update and breaks progress.
continue looks harmless: “just skip an iteration.” But if the update of the condition variable is at the end of the body, continue can prevent it from running. A good practical trick is to update the key variable before the point where continue is possible, so progress doesn’t depend on branching.
GO TO FULL VERSION