| name | go-strings |
| description | Guides the agent to write correct and efficient Go string code. Use when writing or reviewing Go code that iterates over strings, concatenates strings, trims strings, converts between string and []byte, or extracts substrings. Covers rune semantics, string iteration pitfalls, trim vs. suffix/prefix functions, Builder-based concatenation, avoiding useless string/byte conversions, and preventing memory leaks from substrings.
|
Go Strings — Rules and Patterns
1. Runes, bytes, and len
- A Go
string is an immutable sequence of bytes, not runes.
len(s) returns the byte count, not the character/rune count.
- A
rune is an alias for int32 and represents a single Unicode code point.
- Under UTF-8 a code point may occupy 1-4 bytes.
- To count runes, use
utf8.RuneCountInString(s).
s := "cafe\u0301"
fmt.Println(len(s))
fmt.Println(utf8.RuneCountInString(s))
2. String iteration
Prefer the range value variable for rune access
When ranging over a string, the index i is the byte offset of the rune, and the value r is the rune itself. Accessing s[i] gives a single byte, not the rune.
Wrong:
for i := range s {
fmt.Printf("%c", s[i])
}
Correct — iterate over runes:
for i, r := range s {
fmt.Printf("position %d: %c\n", i, r)
}
Accessing the i-th rune
If you need the rune at a logical index (not byte offset), convert to []rune first:
r := []rune(s)[4]
This allocates a new slice (O(n)), so avoid it in hot loops. If the string is known to
be pure ASCII, rune(s[i]) is safe and allocation-free.
3. Trim functions — TrimRight/TrimLeft vs. TrimSuffix/TrimPrefix
These are commonly confused. The semantics are fundamentally different:
| Function | Behaviour |
|---|
strings.TrimRight(s, cutset) | Removes all trailing runes that appear anywhere in cutset, one by one, until a rune not in the set is found. |
strings.TrimSuffix(s, suffix) | Removes the exact trailing suffix substring once. |
strings.TrimLeft(s, cutset) | Same as TrimRight but from the left. |
strings.TrimPrefix(s, prefix) | Same as TrimSuffix but from the left. |
strings.Trim(s, cutset) | Applies both TrimLeft and TrimRight. |
strings.TrimRight("123oxo", "xo")
strings.TrimSuffix("123oxo", "xo")
Rule: When you want to remove a known fixed string from the start/end, use
TrimPrefix / TrimSuffix. When you want to strip a set of characters, use
TrimLeft / TrimRight / Trim.
4. String concatenation
Avoid += in loops
Strings are immutable. Every += allocates a new string and copies all previous
bytes. This is O(n^2) for n concatenations.
Wrong:
func concat(values []string) string {
s := ""
for _, v := range values {
s += v
}
return s
}
Use strings.Builder in loops
func concat(values []string) string {
var sb strings.Builder
for _, v := range values {
sb.WriteString(v)
}
return sb.String()
}
Pre-grow the builder when total size is known or estimable
Computing the total byte length first and calling Grow avoids internal slice
re-allocations. Benchmarks show ~78% speedup over a non-pre-allocated Builder.
func concat(values []string) string {
total := 0
for _, v := range values {
total += len(v)
}
var sb strings.Builder
sb.Grow(total)
for _, v := range values {
sb.WriteString(v)
}
return sb.String()
}
When += is fine
For a small, fixed number of concatenations (roughly <= 5) or one-off formatting,
+=, fmt.Sprintf, or + are acceptable and more readable.
Safety note
strings.Builder is not safe for concurrent use.
5. Avoid useless string <-> []byte conversions
Each conversion between string and []byte allocates and copies because strings
are immutable.
Rule: If input arrives as []byte (from io.Reader, io.ReadAll, etc.) and the
result is also []byte, keep the entire pipeline in []byte. The bytes package
mirrors almost every function in strings:
strings function | bytes equivalent |
|---|
strings.TrimSpace | bytes.TrimSpace |
strings.Split | bytes.Split |
strings.Contains | bytes.Contains |
strings.Index | bytes.Index |
strings.Count | bytes.Count |
Wrong — double conversion:
func getBytes(reader io.Reader) ([]byte, error) {
b, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
return []byte(strings.TrimSpace(string(b))), nil
}
Correct — stay in []byte:
func getBytes(reader io.Reader) ([]byte, error) {
b, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
return bytes.TrimSpace(b), nil
}
Guideline: Before reaching for the strings package, check whether the whole
workflow can use bytes instead.
6. Substrings and memory leaks
Substring expressions like s[a:b] produce a string that shares the same backing
array as the original. If the original string is large (e.g., a log line of thousands
of bytes) and you store only a small substring, the entire original backing array
stays in memory and cannot be garbage-collected.
Leaky:
uuid := log[:36]
s.store(uuid)
Fix: copy the substring
Go >= 1.18 — use strings.Clone:
uuid := strings.Clone(log[:36])
Pre-1.18 — manual deep copy:
uuid := string([]byte(log[:36]))
Note: some linters flag string([]byte(...)) as a "redundant conversion" — this
warning is incorrect in this context because the round-trip deliberately breaks
backing-array sharing.
Substring indexing caveat
s[a:b] operates on byte indices, not rune indices. For multi-byte runes,
convert to []rune first:
s := "Hêllo, World!"
sub := string([]rune(s)[:5])
Quick-reference checklist