1. Why + in a loop can become a problem
When you’re just starting to program, you want to solve problems in the most straightforward way: “need to build some text — so I’ll do text = text + piece”. That’s normal: your brain saves energy and chooses the most obvious move. The problem is that strings in Kotlin (as in many languages) are immutable, and with frequent concatenations we end up creating a bunch of temporary strings.
Imagine that a string is a sheet of paper. You can’t write into the middle of the sheet “in place”. Each time you have to take a new sheet, copy the old contents onto it, and add the new piece. With three concatenations you won’t notice. With three thousand, your computer will notice (and maybe the computer next to it too, because the fans start talking to space).
Let’s look at a typical “naive” example:
fun main() {
var text = ""
for (i in 1..5) {
text += "Line $i\n"
}
print(text)
// Line 1
// Line 2
// Line 3
// Line 4
// Line 5
}
From the result perspective everything is fine. But under the hood, on every iteration a new string is created, the old one is copied into it, and the new fragment is added. The larger the final text, the more copying happens. This isn’t an “error”; the compiler won’t complain, the program will work — it will just do extra work.
An important practical takeaway: if you’re building text in a loop, especially many lines, you almost always should think about StringBuilder.
2. StringBuilder: build text efficiently and predictably
If concatenation with + is like rewriting the text onto a new sheet of paper every time, then StringBuilder is like a notebook where you keep adding pages without retyping the previous ones. It stores a mutable character buffer internally and can efficiently “grow” its contents.
In Kotlin, StringBuilder is used all the time — it’s just often invisible to you. For example, the compiler can optimize some forms of string templates. But in loops and in more complex text assembly, it’s better to take control and explicitly use StringBuilder.
The minimal “skeleton” almost always looks like this:
- create a StringBuilder()
- do many append(...) / appendLine(...)
- at the end, call toString() once and get the final String
Here’s a micro-example:
fun main() {
val sb = StringBuilder()
sb.append("Hello")
sb.append(", ")
sb.append("Kotlin")
println(sb.toString()) // Hello, Kotlin
}
Here you can already see the key difference in mindset: we don’t recreate the final string at each step. We accumulate pieces and at the end we “freeze” the result.
Another nice detail: many append(...) methods return the StringBuilder itself, so sometimes you can write chained code (but don’t overdo it — readability matters more than “beauty”):
fun main() {
val text = StringBuilder()
.append("A")
.append(" + ")
.append("B")
.toString()
println(text) // A + B
}
Line breaks: '\\n' and appendLine()
When you build multi-line text, the main “glue” between lines is the newline. And this is where a small everyday tragedy often begins: somewhere you forgot "\n", somewhere you added two, somewhere you ended up with an extra empty line at the end, and the report looks like it was formatted by a cat walking across the keyboard.
In Kotlin, there are two common approaches.
The first is to manually add the newline character '\\n'. It’s simple and transparent: you explicitly control every line break.
fun main() {
val sb = StringBuilder()
sb.append("First").append('\n')
sb.append("Second").append('\n')
print(sb.toString())
// First
// Second
}
The second is to use appendLine(...). It’s a convenient way to “add a line and a line break”. It’s important to understand that appendLine() is an extension function from the standard library for StringBuilder: it’s called like a “method”, even though technically it’s an extension.
Example:
fun main() {
val sb = StringBuilder()
sb.appendLine("First")
sb.appendLine("Second")
print(sb.toString())
// First
// Second
}
A practical rule: if you’re assembling “report lines” one by one, appendLine(...) usually makes the code cleaner. If you need finer control over characters somewhere (for example, you don’t always want to add a newline), '\\n' can sometimes be clearer.
The discipline of “build → return a string”
It’s very tempting (especially for beginners) to do this: “well, I’m building a report, so I’ll println(...) each line as I go”. It feels simpler. But it quickly leads to calculations, formatting, and console output being mixed into one big tangled ball. And a tangled ball, as we know, gets untangled only in two cases: either on an exam, or in production. Usually at the same time.
The discipline of “build → return a string” means a simple style: a function forms the text and returns it, and the caller prints it. This gives three big benefits.
- The function is easier to test: it deterministically returns a string, and you can compare it to the expected one.
- It’s easier to reuse: today you print to the console; tomorrow you may want to send it somewhere else — and the text is already ready.
- You clearly separate responsibilities: the function “builds”, main “shows”.
A small example:
fun buildGreeting(name: String): String {
val sb = StringBuilder()
sb.append("Hello, ")
sb.append(name)
sb.append('!')
return sb.toString()
}
fun main() {
val text = buildGreeting("Alice")
println(text) // Hello, Alice!
}
Notice: inside buildGreeting() there isn’t a single println. It does one job — builds a string.
Newlines without chaos: two practical styles
When you build many lines, the “extra newline” problem appears almost always. And the important thing isn’t so much “which way is correct”, but “that you choose a rule and stick to it”. Then the output will be stable, and your brain will stop guessing each time whether an empty line will appear at the end.
There are two popular styles.
Style one is “a newline after every line, including the last one”. It’s easier to implement and is often perfectly fine for console reports. You just call appendLine(...) on each iteration, and that’s it.
fun buildList(items: List<String>): String {
val sb = StringBuilder()
for (item in items) {
sb.appendLine(item)
}
return sb.toString()
}
Style two is “a newline only between lines”. It’s useful if it’s important that the last line does not end with a newline (for example, it can matter for string comparisons or for some formats). Then we don’t always add '\\n'; we do it conditionally, usually based on the index.
fun buildListNoTrailingNewline(items: List<String>): String {
val sb = StringBuilder()
for (i in items.indices) {
sb.append(items[i])
if (i != items.lastIndex) sb.append('\n')
}
return sb.toString()
}
Both approaches work. In console utilities, people more often choose the first, because it’s simpler and doesn’t force you to think about the last element each time. But if you start using the second style, use it consistently within the same module; otherwise reports will start behaving “sometimes like this, sometimes like that”.
3. Example: an expenses report using StringBuilder
Up to this point, we’ve been learning how to format strings so they look decent. Now let’s imagine our learning console app stores expenses as a list of pairs: category and amount. We want to build a multi-line report: a header, lines with categories, and a total at the end. And this is exactly where StringBuilder naturally fits.
Let’s start with small formatting for a single line. We already know padEnd/padStart, so we’ll make a “table” with 2 columns: category on the left, amount on the right.
fun formatRow(category: String, amount: Int): String {
val left = category.padEnd(12)
val right = amount.toString().padStart(6)
return left + right
}
Here + is perfectly fine: we concatenate two short strings once. The problem starts when there are hundreds of such lines and we concatenate them in a loop into one big string.
Now let’s write a function that builds the entire report and returns a single string:
fun buildExpensesReport(expenses: List<Pair<String, Int>>): String {
val sb = StringBuilder()
sb.appendLine("== EXPENSES REPORT ==")
var total = 0
for ((category, amount) in expenses) {
sb.appendLine(formatRow(category, amount))
total += amount
}
sb.appendLine("Total: $total")
return sb.toString()
}
Notice how “smooth” the logic is: we iterate through the data, add lines, and add the total at the end. No “glued together” println in the middle, no result += ... in a loop.
And let’s test it in main:
fun main() {
val expenses = listOf(
"food" to 1200,
"transport" to 350,
"coffee" to 250
)
val report = buildExpensesReport(expenses)
print(report)
// == EXPENSES REPORT ==
// food 1200
// transport 350
// coffee 250
// Total: 1800
}
Note one small detail: we used print(report), not println(report), because the report already contains line breaks. This isn’t a “mandatory rule”, but it makes it easier to control whether an extra empty line accidentally appears at the end.
Make it a bit more complex: add a table header
When a report gets bigger, you want to visually separate blocks. StringBuilder lets you do that very explicitly:
fun buildExpensesReportV2(expenses: List<Pair<String, Int>>): String {
val sb = StringBuilder()
sb.appendLine("== EXPENSES REPORT ==")
sb.appendLine("CATEGORY".padEnd(12) + "AMOUNT".padStart(6))
sb.appendLine("------------------")
for ((category, amount) in expenses) {
sb.appendLine(formatRow(category, amount))
}
return sb.toString()
}
The key idea is visible here: a report is basically a “text script”, and StringBuilder helps you write that script step by step.
4. When StringBuilder is not needed: joinToString() and ready-made strings
It’s very easy to fall in love with StringBuilder and start using it everywhere, like a universal hammer. But practice is calmer: if you already have a List<String> of ready-made strings, then joinToString("\n") is often simpler and more readable. StringBuilder is more useful when strings are formed along the way, there are conditions, blocks, headers, totals, and you want to assemble text as a “script”.
Let’s compare the options in a small table:
| Approach | When it’s good | When it’s bad |
|---|---|---|
|
1–3 concatenations, short string, “quick sketch” | Loops, hundreds of lines, large texts |
|
You already have a List<String> and need to join it | Complex branching, headers/totals, controlled blank lines |
|
Step-by-step building, loops, large reports, newline control | Too much “noise” for small strings |
The core idea: StringBuilder is not “speed magic”, but a tool for discipline. It literally nudges you toward the style of “build the result first, then return it”, and that makes code more stable and easier to understand.
You can even draw a small diagram of how the flow should look:
flowchart TD
A[Data: expenses] --> B[Format lines]
B --> C[StringBuilder: append / appendLine]
C --> D["Final string: toString()"]
D --> E[print/println in main]
5. Common mistakes when working with StringBuilder
Mistake No. 1: keep doing result += ... in a loop “out of habit”.
Usually this happens not because of lack of knowledge, but because of muscle memory: your hand reaches for += because it’s faster to type. As a result, the program starts creating lots of temporary strings. A good habit: if you see a loop and “building text”, almost reflexively introduce val sb = StringBuilder().
Mistake No. 2: print inside a function that is supposed to build a string.
When buildReport() calls println(...) internally, you lose control over the output: you can’t easily change where it prints, it’s hard to test, and hard to reuse. It’s much more robust when the function returns a string and printing happens in main.
Mistake No. 3: get confused about line breaks and end up with extra blank lines.
The most common everyday version: at the end of the report, an “extra emptiness” suddenly appears because you used both appendLine() and then println(report). Choose a rule: either the report itself contains line breaks and you do print(report), or you build it without a final newline and print it via println.
Mistake No. 4: mix different building styles in one place.
If some lines are built with StringBuilder, some with joinToString, and some with + in a loop, the code quickly becomes hard to read: you keep switching mental models. It’s better to choose one dominant style for a specific task: “build the report with a builder”, “join a list of ready strings with joinToString”.
Mistake No. 5: forget to call toString() and try to return a StringBuilder.
Sometimes beginners return the StringBuilder itself from a function and then wonder why the types don’t match or why “it’s almost a string”. StringBuilder is a container for building. The final product is a string. So, in almost all cases, the final string should appear in one place: return sb.toString().
GO TO FULL VERSION