When you start learning Go, the syntax feels clean and the speed feels amazing. But there is a trap many beginners fall into: writing code that works perfectly for small tasks but crawls to a halt when the data gets bigger.
Recently, I was working on an ASCII Art Generator. The logic was simple: take a string, find the corresponding ASCII characters in a template file, and print them out. However, I noticed that as my input text got longer, the program started lagging.
The culprit? How I was collecting my results. Let’s look at the two "silent" performance killers I found in my own code.
1. The String Concatenation Trap
In my project, I had a function to build the final output string. I was using the += operator to join every line together.
The "Befo…
When you start learning Go, the syntax feels clean and the speed feels amazing. But there is a trap many beginners fall into: writing code that works perfectly for small tasks but crawls to a halt when the data gets bigger.
Recently, I was working on an ASCII Art Generator. The logic was simple: take a string, find the corresponding ASCII characters in a template file, and print them out. However, I noticed that as my input text got longer, the program started lagging.
The culprit? How I was collecting my results. Let’s look at the two "silent" performance killers I found in my own code.
1. The String Concatenation Trap
In my project, I had a function to build the final output string. I was using the += operator to join every line together.
The "Before" Code (Slow):
func OutputCharacters(input []string, charmap map[rune][]string) string {
res := ""
for _, line := range BuildASCII(input, charmap) {
res += line + "\n" // The Hidden Performance Killer
}
return res
}
The Technical "Why"
In Go, strings are immutable. This means once a string is created in memory, it can never be changed.
When you run res += line, Go doesn’t just "tack" the new text onto the end. Instead, it performs a costly cycle:
- It allocates a brand new memory block large enough for the old string + the new line.
- It copies every single byte from the old string into the new one.
- It copies the new line into the new memory.
- It throws away the old string for the Garbage Collector to clean up.
As your loop grows, you are copying the same data over and over again. This creates a *Time Complexity of *. If you have 1,000 lines, you aren’t just doing 1,000 operations; you’re performing millions of byte copies!
The Fix: strings.Builder
Go provides strings.Builder, which uses a mutable byte slice internally. It grows smarter (usually doubling in size) so it doesn’t have to copy everything every time.
func OutputCharacters(input []string, charmap map[rune][]string) string {
var builder strings.Builder
lines := BuildASCII(input, charmap)
for _, line := range lines {
builder.WriteString(line)
builder.WriteByte('\n')
}
return builder.String() // Final string is created only ONCE
}
2. The Unallocated Slice Issue
Inside my BuildASCII function, I was creating a slice of strings to hold the art, but I was initializing it as an empty slice.
The Problem:
var res []string // Length: 0, Capacity: 0
A slice in Go is backed by an underlying array. When you append to a slice that is full, Go has to "move house":
- It finds a new, larger spot in memory.
- It copies all current elements to the new spot.
- It updates the slice pointer.
In my ASCII project, I knew that every word in the input would result in exactly 8 lines of ASCII art. By not telling Go this, I was forcing the program to stop and "move house" (reallocate) multiple times as the list grew.
The Fix: Pre-allocation with make
If you can calculate or even estimate the size, you should pre-allocate the capacity. This "reserves" the memory upfront.
func BuildASCII(input []string, charmap map[rune][]string) []string {
// We know the exact size: 8 lines per input word
totalExpected := len(input) * 8
res := make([]string, 0, totalExpected) // Length 0, but Capacity is set!
for _, word := range input {
// ... build lines ...
res = append(res, line) // Zero reallocations happen now!
}
return res
}
Conclusion
When you’re a beginner, "making it work" is the priority. But as you grow, "making it efficient" becomes the goal. By understanding how Go handles memory, you can prevent your programs from slowing down as they scale.
Have you run into these "slow loops" before? What other beginner traps should I cover in Part 2? Let me know below!