If you've been writing Kotlin for Android, you've probably defaulted to forEach whenever you needed to iterate over a collection. It sits at the top of your IDE's autocomplete menu, it looks sleek, and it keeps your code compact. But what if that habit is making your code harder to parse?
The core argument gaining traction among experienced developers: for loops deserve more credit than they typically receive. The key claim is that forEach conceals imperative operations behind a declarative-looking facade, requiring readers to investigate implementation details just to grasp what the code actually does.
Quick take
- ForEach appears functional but frequently obscures imperative side effects
- Kotlin developers often select forEach reflexively without considering alternatives
- Traditional for loops expose control flow and state changes immediately
- Built-in higher-order functions like filter and map can handle simple transformations more expressively
- The best practice is intentional selection based on what your code genuinely requires
The hidden cost of forEach
Here's the mental trap many Kotlin developers fall into. When scanning code filled with chains of map, filter, or forEach calls, readers instinctively expect pure operations—transformations that take inputs and produce outputs without altering external state. This assumption lets them grasp the overall flow without examining every line.
ForEach shatters that expectation. It presents itself as functional while its lambda body frequently mutates variables outside its scope. Imagine loops that populate mutable collections, emit logs, or trigger UI updates. None of those side effects are apparent from the outside; you must read every lambda to know what changed.
The observation from César Puerta Catcher cuts to the core: forEach behaves like an imperative construct draped in functional syntax. The brevity it offers comes at the expense of transparency about actual behavior.
Our analysis: weighing the real trade-offs
Let's acknowledge upfront: forEach isn't broken. It works perfectly well for genuinely trivial operations—dumping values to the console or running read-only checks with no observable effects.
The trouble starts when developers apply forEach uniformly across their codebase. In that environment, reviewers can't instantly tell pure data reshaping from code that mutates shared state. Every loop becomes an investigation, slowing down reviews and creating blind spots where bugs hide.
The solution isn't always abandoning forEach for a traditional loop either. Kotlin's suite of higher-order functions frequently communicates intent more effectively. When you see filter or map, you immediately understand: this is data transformation happening. No need to inspect lambda internals.
The calculus shifts when complexity increases. The moment your loop involves early exits, branching logic, or several points where state changes, a for loop provides levers that forEach simply doesn't offer. Functional pipelines grow unwieldy the instant you start blending pure transformations with observable effects.
For Android developers—where network requests, database writes, and view mutations dominate—these considerations carry real weight. Your production code almost certainly contains more side effects than you consciously track. Surfacing those behaviors helps your entire team reason about what the application actually does.
A practical demonstration
Imagine processing a collection of integers where you want to keep only the odd values and double them:
“`kotlin
Val source = listOf(5, 12, 7, 18, 3, 9)
Val results = mutableListOf<Int>()
Source.forEach { item ->
If (item % 2 != 0) {
Results.add(item * 2)
}
}
“`
This gets the job done. But does it telegraph purpose? Hardly. Anyone scanning this must mentally execute the condition and the insertion before grasping the objective.
Compare the functional alternative:
“`kotlin
Val results = source
.filter { it % 2 != 0 }
.map { it * 2 }
“`
Two concise lines. No visible mutations. The purpose—retain odd numbers, then double them—parses as natural language.
When additional complexity enters the picture, a conventional for loop earns its place:
“`kotlin
Val results = mutableListOf<Int>()
For (item in source) {
If (item % 2 != 0) {
Results.add(item * 2)
Logger.debug("Processed odd value: $item")
}
}
“`
That logging statement? Invisible in the forEach version. Now it's unmistakable: this loop performs logging alongside its filtering work.
When to reach for which construct
| Situation | Recommended approach | Reasoning |
|---|---|---|
| Pure data reshaping | filter + map chain | Guarantees no hidden mutations |
| Combining transformations with observable effects | for loop | Every action surfaces explicitly |
| Trivial read-only traversals | forEach | Acceptable when nothing changes |
| Early termination or complex branching | for loop | forEach lacks break/continue flexibility |
Key takeaways
- ForEach appears functional but frequently obscures imperative side effects
- Kotlin developers often select forEach reflexively without considering alternatives
- Traditional for loops expose control flow and state changes immediately
- Built-in higher-order functions like filter and map can handle simple transformations more expressively
- The best practice is intentional selection based on what your code genuinely requires
Frequently asked questions
Does this mean I should abandon forEach completely?
No. ForEach remains reasonable for trivial, self-contained operations. The goal is conscious decision-making instead of automatic reflex.
When does a traditional for loop become the better option?
When your iteration involves intricate conditional logic, early exits, multiple mutation points, or anything that would bloat the lambda body beyond a quick glance.
Are higher-order functions like filter and map universally superior?
They excel at expressing pure transformations elegantly, but they become cumbersome when transformation logic mingles with side effects. Pick the tool that fits the job.
Is it worth refactoring existing forEach loops to these patterns?
Only when you're already touching that code for other reasons. Don't schedule a dedicated rewrite—the benefit doesn't justify the effort.
How do I bring this up with my team?
Raise it during your next code review. Find a representative forEach loop and examine whether its choice makes the code's purpose immediately clear. Teams tend to embrace this once they see it applied to their own work.