C++ Loops: Choosing Between for, while, do-while and Range-for

Key takeaways

Choose the right loop: counted for, condition-driven while, do-while for at-least-once, range-based for, break/continue rules, off-by-one, and floating-point loop pitfalls. Complete guide with practical examples.

Why Loop Choice Actually Matters

C++ gives you four ways to repeat work — for, while, do-while, and range-based for — and beginners often treat them as interchangeable syntax sugar. They aren’t. Each form encodes a different claim about your data: a counted for says “I know the shape of this iteration before it starts,” a while says “an external condition, not a counter, decides when to stop,” and a range-based for says “I only care about the elements, not their positions.” Picking the wrong form doesn’t just read worse — it removes information the compiler and the next engineer both rely on. A while (true) loop with a hidden break two screens down hides the exit condition from anyone skimming the function; a hand-rolled index loop where a range-based for would do throws away the compiler’s strongest hint that you’re doing simple linear traversal, which matters for optimizations like auto-vectorization.

This distinction stops being academic the moment you’re debugging a crash at 2 a.m. or staring at a profiler flame graph that says 30% of your hot path is spent in a copy constructor you didn’t know was being called. The sections below cover the mechanics of each loop form, but the deeper goal is building the judgment to pick the right one — and to recognize, in review, when someone else picked the wrong one.

The for Loop

The for loop packages initialization, condition, and increment together — ideal when you know the iteration count or range up front:

#include <iostream>

int main() {
    // Count from 1 to 5
    for (int i = 1; i <= 5; i++) {
        std::cout << i << ' ';
    }
    std::cout << '\n';  // 1 2 3 4 5

    // Count down
    for (int i = 5; i >= 1; i--) {
        std::cout << i << ' ';
    }
    std::cout << '\n';  // 5 4 3 2 1

    // Step by 2
    for (int i = 0; i < 10; i += 2) {
        std::cout << i << ' ';
    }
    std::cout << '\n';  // 0 2 4 6 8
}

The three parts of the for header are all optional. Any of them can be empty:

int i = 0;
for (; i < 5; ) {
    std::cout << i << ' ';
    i++;
}
// Same as while (i < 5) { ...; i++; }

Performance: Cache-Friendly Iteration Order and Loop Unrolling

The order in which a for loop touches memory can matter as much as the algorithm itself. C++ stores multidimensional arrays and std::vector<std::vector<T>> rows contiguously — a 2D array is really a flat block of memory addressed row by row. If the outer loop walks columns and the inner loop walks rows, every single access jumps to a different cache line, and the CPU’s hardware prefetcher — which is tuned to spot sequential access — can’t help you at all:

#include <vector>

constexpr int N = 2000;
std::vector<std::vector<int>> grid(N, std::vector<int>(N, 1));

// CACHE-HOSTILE: column-major traversal of a row-major structure
long long sumBad = 0;
for (int c = 0; c < N; c++) {
    for (int r = 0; r < N; r++) {
        sumBad += grid[r][c];  // jumps N ints between accesses
    }
}

// CACHE-FRIENDLY: row-major traversal matches memory layout
long long sumGood = 0;
for (int r = 0; r < N; r++) {
    for (int c = 0; c < N; c++) {
        sumGood += grid[r][c];  // sequential — stays in the same cache line
    }
}

On a machine with a typical 64-byte cache line, the cache-hostile version can be several times slower for large N, purely from cache misses — the two loops do the exact same number of additions. This is one of the few places where swapping the order of two for headers, with zero change in logic, produces a measurable and sometimes dramatic speedup. It’s also why library functions that operate on matrices (BLAS-style kernels, image processing loops) are written with iteration order as a first-class design decision, not an afterthought.

Loop unrolling is the second lever, and it’s mostly not yours to pull directly. When the trip count is known at compile time and the loop body is small, -O2/-O3 will often unroll it automatically — replacing several iterations with straight-line code to cut branch and increment overhead. You can hint this with #pragma GCC unroll N or #pragma unroll (compiler-specific), but hand-unrolling loops yourself is rarely worth the readability cost today; modern compilers do it more reliably than a manually unrolled loop that’s now harder to maintain. Where manual unrolling still earns its keep is in tight, measured hot loops — SIMD-friendly numeric kernels — where you’ve already profiled and know the compiler isn’t vectorizing on its own.


The while Loop

while is best when the number of iterations isn’t known in advance:

#include <iostream>

int main() {
    // Read until the user enters 0
    int n;
    int sum = 0;
    std::cout << "Enter numbers (0 to stop): ";
    while (std::cin >> n && n != 0) {
        sum += n;
    }
    std::cout << "Sum: " << sum << '\n';
}
// Retry until success
int attempts = 0;
bool connected = false;
while (!connected && attempts < 3) {
    connected = tryConnect();
    attempts++;
}
if (!connected) {
    std::cerr << "Failed after 3 attempts\n";
}

How while Loops Turn Into Infinite Loops

while puts the exit condition entirely in your hands, which is exactly why it’s the loop form most likely to run forever by accident. The classic beginner version is forgetting to update the variable the condition depends on — while (n != 0) { sum += n; } with no n = getNext() inside the body. That bug is usually easy to spot because the program visibly hangs.

The more dangerous version is subtler: an update that exists but can never actually satisfy the exit condition. Counting down with an unsigned type is the canonical example:

// WRONG — i is unsigned, so i-- after i reaches 0 wraps to a huge positive number
unsigned int i = 5;
while (i >= 0) {
    std::cout << i << ' ';
    i--;  // when i is 0, this wraps to UINT_MAX, not -1
}
// runs effectively forever (billions of iterations), not a compile error

i >= 0 is always true for an unsigned type, so this compiles cleanly, passes a quick glance in review, and only reveals itself when someone actually runs it — or worse, only under an input size that makes the loop’s true iteration count visible as a hang rather than a slow-but-finite loop. The fix is either to use a signed counter when the value can go negative in your logic, or to restructure the loop to break on the boundary explicitly (if (i == 0) break; before the decrement). This is one of the reasons some style guides prefer signed integers for loop counters even when the quantity being counted is logically non-negative — size_t from container.size() is unsigned by definition, and mixing it into a countdown is a recurring source of exactly this bug.


The do-while Loop

do-while runs the body first, then checks the condition. Use it when the body must run at least once:

#include <iostream>

int main() {
    // Input validation — show prompt, read, then check
    int value;
    do {
        std::cout << "Enter a positive number: ";
        std::cin >> value;
    } while (value <= 0);

    std::cout << "You entered: " << value << '\n';
}
// Menu-driven program
int choice;
do {
    std::cout << "1. Play  2. Options  3. Quit\nChoice: ";
    std::cin >> choice;

    switch (choice) {
        case 1: play(); break;
        case 2: showOptions(); break;
    }
} while (choice != 3);

do-while is the least-used of the three classic loop forms in most C++ codebases, and that’s partly deserved: putting the condition after the body means a reader has to reach the bottom of the loop before they know when it stops, which is a real readability cost for anything longer than a handful of lines. It also has a well-known syntax trap — omitting the semicolon after while (condition) is a compile error, but more importantly, some engineers reflexively rewrite do { ... } while (cond); as a while loop with a duplicated first call, which reintroduces a maintenance hazard (the duplicated code drifts out of sync). Reserve do-while for the narrow case it’s actually good at — body-must-run-once-before-checking — and don’t reach for it just because a while (true) with a break at the end “feels” less idiomatic; in practice, many teams prefer the while (true) form specifically because the exit condition can be written inline with a comment explaining why, which a bare while (cond); trailer can’t do as clearly.


Range-Based for (C++11)

For iterating containers, range-based for is the clearest option:

#include <vector>
#include <string>
#include <iostream>

int main() {
    std::vector<int> nums = {3, 1, 4, 1, 5, 9};

    // By value — copies each element (fine for ints)
    for (int n : nums) {
        std::cout << n << ' ';
    }

    // By const reference — no copy, read-only (use for large types)
    std::vector<std::string> words = {"hello", "world"};
    for (const std::string& w : words) {
        std::cout << w << ' ';
    }

    // By reference — can modify elements
    for (int& n : nums) {
        n *= 2;  // double each element
    }

    // auto — let the compiler deduce the type
    for (const auto& w : words) {
        std::cout << w.size() << ' ';
    }
}

Rule of thumb for element type in range-for:

  • Small types (int, double): use auto or the type by value
  • Large types (string, vector, struct): use const auto& — avoids copy
  • When modifying elements: use auto&

The Silent Copy Trap

for (auto x : container) is the single most common way beginners — and not-so-beginners — accidentally write an O(n) copy loop that looks identical to a zero-copy one. The syntax gives you no visual signal that anything expensive is happening: auto deduces the element’s value type, so every iteration constructs a full copy of the element, runs its copy constructor (and eventually its destructor at the end of the iteration), and only then lets you read it. For int or double this is free. For std::string, std::vector<T>, or a struct with several such members, it means a heap allocation and a memcpy-equivalent on every single pass — code that still compiles, still produces the correct output, and just silently costs far more than it needs to. const auto& fixes this by binding a reference to the existing element instead of constructing a new one; it should be your default for anything larger than a primitive, and it composes correctly with const containers too, whereas auto& will fail to compile against a const std::vector<T>&.

There’s a related gotcha specific to one container: std::vector<bool> is not a normal vector — the standard library special-cases it as a bit-packed structure to save memory, so operator[] doesn’t return a real bool&, it returns a proxy object. That means for (auto& b : boolVector) doesn’t compile the way you’d expect for a normal vector<T>, because there’s no genuine bool lvalue to bind a reference to — you either take auto b by value (a proxy, not a copy of a bool, which usually still works for read-only iteration) or explicitly use std::vector<bool>::reference when you need to mutate in place. It’s one of the more infamous “gotchas” in the standard library specifically because it breaks the intuition every other vector<T> teaches you.

A Production Bug: Iterator Invalidation Inside a Range-For

I once maintained a message-deduplication service that kept a std::map<uint64_t, TimePoint> of recently seen message IDs, with a background sweep that walked the map every few seconds and erased anything older than a TTL. The first version of that sweep looked reasonable at a glance:

// BUGGY — erasing from a map while range-for is iterating it
for (const auto& [id, seenAt] : recentIds) {
    if (isExpired(seenAt)) {
        recentIds.erase(id);  // invalidates the iterator range-for is using internally
    }
}

It passed code review and ran fine in staging, where the map rarely had more than a few hundred entries and expirations were sparse. In production, under a traffic spike, the map grew into the tens of thousands, and the sweep started erasing a meaningful fraction of entries in a single pass. Range-based for desugars to a hidden begin()/end()/operator++ loop, and calling erase() on the very iterator that loop is about to increment is undefined behavior — for std::map specifically, erasing the current node invalidates that node’s iterator (though, notably, not the others, which is what made this bug so easy to miss in a quick read: it “usually” didn’t crash). It manifested as an intermittent segfault that only showed up under load, which made it miserable to reproduce locally — I eventually caught it by running the service under AddressSanitizer with production-shaped synthetic traffic, where it flagged the use-after-free immediately. The fix was the standard pattern: drop the range-for for an explicit iterator loop and use the iterator that erase() returns:

// FIXED — erase() returns the next valid iterator
for (auto it = recentIds.begin(); it != recentIds.end(); ) {
    if (isExpired(it->second)) {
        it = recentIds.erase(it);
    } else {
        ++it;
    }
}

The broader lesson I took from that incident: range-based for is deliberately designed to hide the iterator from you for readability, but that’s exactly the wrong tool the moment you need to mutate the container you’re iterating. If a loop body calls erase, insert, or anything that can reallocate or restructure the container, reach for the explicit iterator form (or better, build a list of keys to remove and erase them in a second pass) rather than fighting the range-for syntax to make it “work.”

A Perf Regression From Copying Instead of Referencing

The value-vs-reference distinction above isn’t hypothetical, either. On a different project, a hot path that processed incoming order-book updates had a loop written as for (auto order : pendingOrders), where Order was a moderately heavy struct carrying a couple of std::string fields (symbol, venue) alongside the numeric fields. It worked correctly and nobody noticed anything wrong until a routine profiling pass — perf record plus a flame graph — showed a surprising chunk of CPU time sitting in std::string’s copy constructor and the associated allocator calls, in a function that, by its logic, shouldn’t have been allocating memory at all in its steady-state path. Tracing it back to that single auto instead of const auto& and changing it was a one-line fix that measurably dropped both CPU time and allocator churn on that path. Nothing about the original code was wrong — it was correct C++ — but “correct” and “doesn’t silently allocate on every iteration of your hottest loop” are different bars, and range-for’s terseness makes it easy to clear the first one without noticing you missed the second. On anything in a hot path, I now treat auto (by value) in a range-for as a decision that needs a reason, not a default.


Choosing the Right Loop

LoopBest when
forNumber of iterations known; index needed; iterating 0..n
whileCondition-driven; could run 0 times; EOF or retry patterns
do-whileBody must run at least once; input validation; menu loops
Range-forIterating a container; no index needed

break and continue

break exits the innermost enclosing loop (or switch):

std::vector<int> nums = {2, 5, 3, 8, 1, 7};

// Find first number > 6
for (int n : nums) {
    if (n > 6) {
        std::cout << "Found: " << n << '\n';  // Found: 8
        break;  // stop searching
    }
}

continue skips the rest of the current iteration:

// Print only odd numbers
for (int i = 1; i <= 10; i++) {
    if (i % 2 == 0) continue;  // skip even numbers
    std::cout << i << ' ';
}
// 1 3 5 7 9

break and continue only affect the innermost enclosing loop. They cannot break out of multiple levels at once.


Nested Loops

// Multiplication table
for (int i = 1; i <= 5; i++) {
    for (int j = 1; j <= 5; j++) {
        std::cout << i * j << '\t';
    }
    std::cout << '\n';
}

Exiting Nested Loops

C++ has no labeled break. Options:

Option 1: Use a function and return

// Cleanest approach
std::pair<int,int> findTarget(const std::vector<std::vector<int>>& grid, int target) {
    for (int r = 0; r < (int)grid.size(); r++) {
        for (int c = 0; c < (int)grid[r].size(); c++) {
            if (grid[r][c] == target) {
                return {r, c};  // return exits both loops
            }
        }
    }
    return {-1, -1};  // not found
}

Option 2: Flag variable

bool found = false;
for (int r = 0; r < rows && !found; r++) {
    for (int c = 0; c < cols && !found; c++) {
        if (grid[r][c] == target) {
            found = true;
            result = {r, c};
        }
    }
}

Option 3: goto (use sparingly)

// Only with team agreement — goto is clearer than complex flag logic here
for (int r = 0; r < rows; r++) {
    for (int c = 0; c < cols; c++) {
        if (grid[r][c] == target) goto done;
    }
}
done:

Off-by-one errors, stray semicolons, and float counters

Off-by-One Errors

The most common loop bug. Use half-open ranges [0, n) consistently:

std::vector<int> v = {10, 20, 30, 40, 50};

// WRONG: i <= v.size() — goes one past the end (undefined behavior)
for (int i = 0; i <= (int)v.size(); i++) {
    std::cout << v[i] << '\n';  // v[5] is out of bounds
}

// CORRECT: i < v.size()
for (int i = 0; i < (int)v.size(); i++) {
    std::cout << v[i] << '\n';
}

// Better: use range-for when index not needed
for (int x : v) {
    std::cout << x << '\n';
}

Off-by-one bugs happen because <= and < look almost identical at a glance, and both compile without warning — the compiler has no way to know your intent, only your literal condition. v.size() returns size_t, an unsigned type, which compounds the risk: comparing a signed loop counter against it without a cast (as the example does with (int)v.size()) triggers an implicit signed-to-unsigned conversion, and v.size() - 1 on an empty container wraps around to a huge unsigned value instead of going negative. The half-open convention [0, n) — start inclusive, end exclusive — is worth internalizing precisely because it composes correctly everywhere: an empty range is just i < 0, which is trivially false, with no special case needed. Whenever a loop’s only job is “touch every element,” prefer range-based for; it eliminates the index entirely, which eliminates the entire class of off-by-one bugs at the syntax level rather than relying on the author getting the comparison operator right every time.

Stray Semicolon

for (int i = 0; i < 10; i++);  // semicolon — empty loop body
{
    std::cout << "runs once after the loop\n";  // NOT inside the loop!
}

// Correct:
for (int i = 0; i < 10; i++) {
    std::cout << i << '\n';
}

This one is purely a whitespace illusion — the semicolon terminates the for statement right there, so the braced block that follows is just an ordinary scope that happens to run once, immediately after the (now-empty) loop finishes. The indentation lies to you; the compiler doesn’t care about indentation at all. It’s the kind of bug that’s nearly invisible in a diff review because the line with the actual mistake (for (...);) looks completely unremarkable on its own — you only catch it by noticing the loop body never seems to execute more than once, or by turning on -Wall, which GCC and Clang will flag as -Wempty-body in many cases. Making that warning a build error is cheap insurance against this exact bug.

Floating-Point Loop Counter

// WRONG — floating-point accumulation error may miss the exit condition
for (double x = 0.0; x != 1.0; x += 0.1) {
    // x may never equal exactly 1.0 due to rounding
    // loop may run forever or stop at wrong value
}

// CORRECT — integer counter, compute float from it
for (int i = 0; i < 10; i++) {
    double x = i * 0.1;  // 0.0, 0.1, 0.2, ..., 0.9
    // process x
}

The root cause is that most decimal fractions — 0.1 included — have no exact representation in binary floating point (IEEE 754), the same reason 0.1 + 0.2 == 0.3 evaluates to false in C++. Each addition of 0.1 accumulates a tiny rounding error, and those errors compound differently depending on the exact sequence of operations, so the loop might terminate one iteration early, one iteration late, or — in the worst case, if x overshoots 1.0 without ever landing exactly on it — never terminate at all, spinning until it hits some unrelated numeric edge case. Comparing floats with == is a code smell in general, not just in loop conditions, but loop counters are where it bites hardest because the bug is intermittent-looking: it can appear to work for small ranges and fail only once accumulated error crosses a threshold. Driving the loop with an integer and deriving the float value each iteration, as shown above, sidesteps the entire problem because integer comparison has no rounding error to accumulate.

Modifying a Container While Iterating

std::vector<int> v = {1, 2, 3, 4, 5};

// WRONG — erasing invalidates the iterator
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it % 2 == 0) {
        v.erase(it);  // iterator is now invalid!
    }
}

// CORRECT — erase returns next valid iterator
for (auto it = v.begin(); it != v.end(); ) {
    if (*it % 2 == 0) {
        it = v.erase(it);  // it now points to next element
    } else {
        ++it;
    }
}
// v: {1, 3, 5}

The underlying rule is that mutating operations on a std::vector — erase, insert, push_back when it triggers reallocation — invalidate iterators at or after the point of mutation, and using an invalidated iterator (including just comparing it against end()) is undefined behavior, not a guaranteed crash. That’s what makes this bug class so easy to ship: it frequently appears to work, especially in debug builds or small test inputs, and only misbehaves once the container size or allocator behavior changes. std::map and std::set are more forgiving — erasing one node only invalidates that node’s iterator, which is why the map example earlier in this post could go for months without visibly failing — but “more forgiving” is not “safe,” and relying on that nuance is a trap in itself, as I found out. When the goal is simply “remove everything matching a predicate” rather than something more involved, the erase-remove idiom (v.erase(std::remove_if(v.begin(), v.end(), pred), v.end()), or std::erase_if(v, pred) since C++20) expresses the intent directly and sidesteps manual iterator bookkeeping entirely — reach for it before hand-writing an iterator loop whenever the logic fits.


Two classic loops: a prime sieve and Fibonacci

Prime Number Sieve

#include <vector>
#include <iostream>

std::vector<int> sieve(int n) {
    std::vector<bool> is_prime(n + 1, true);
    is_prime[0] = is_prime[1] = false;

    for (int i = 2; i * i <= n; i++) {
        if (is_prime[i]) {
            for (int j = i * i; j <= n; j += i) {
                is_prime[j] = false;
            }
        }
    }

    std::vector<int> primes;
    for (int i = 2; i <= n; i++) {
        if (is_prime[i]) primes.push_back(i);
    }
    return primes;
}

int main() {
    auto primes = sieve(50);
    for (int p : primes) std::cout << p << ' ';
    // 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47
}

Fibonacci Sequence

#include <iostream>

int main() {
    int n = 10;
    long long a = 0, b = 1;

    for (int i = 0; i < n; i++) {
        std::cout << a << ' ';
        long long next = a + b;
        a = b;
        b = next;
    }
    // 0 1 1 2 3 5 8 13 21 34
}

Choosing a loop and avoiding the classic bugs

  • for for known iteration counts and ranges; while for condition-driven loops; do-while when body runs at least once
  • Range-based for is the cleanest way to iterate containers — use const auto& for large types
  • break exits the innermost loop only; continue skips to the next iteration
  • Off-by-one: use i < n (not i <= n) with zero-based indexing; range-for avoids this entirely
  • Floating-point counters: use integer counters and compute the float — never test float == float as a loop condition
  • Nested loop exit: refactor into a function with return, or use a flag variable
  • Modifying containers while iterating: use it = v.erase(it) pattern or use algorithms like std::remove_if