1. String model in Go
When you’re just starting out, a string feels like a “list of letters”. In Go that feeling quickly breaks against reality: a string is a sequence of bytes, and most often those bytes represent UTF-8 text. This is not pedantry for its own sake: once you understand “what a string is made of”, you stop running into weird bugs like “why len("γειασου") isn’t 6” and “why s[0] is not a letter”.
Go as a language generally likes to simplify life via constraints: for example, Go source files must be UTF‑8, so the tools and compiler don’t have to live in a world of “what if this is UTF‑16 with a BOM and some extra Latin text”. This fits well with the idea of normalization: we agree on a format up front, so the code later is simpler and more reliable.
Key properties of string to keep in mind:
- a string is immutable;
- len(s) gives the number of bytes, not “characters”;
- indexing s[i] gives one byte, not a “character”;
- for range over a string iterates over runes (Unicode code points), decoding UTF‑8.
To make it stick, here’s a small example that shows “bytes vs. runes” and why a “heart” can take more than one byte.
package main
import "fmt"
func main() {
s := "Go❤️"
fmt.Println(len(s)) // 6 (bytes)
cnt := 0
for range s {
cnt++
}
fmt.Println(cnt) // 3 (runes)
}
len, indexing, and range: three views of a string
Usually string problems show up not because strings are hard, but because we accidentally use the wrong view. For example, you wanted “the first character” but got “the first byte”. You wanted “the number of letters” but got “the number of bytes”. And the most annoying part: in plain ASCII text everything may work “as if it’s correct”, but with non-ASCII text it suddenly breaks. Classic: “it worked on my laptop”.
Let’s put this into a small table as a cheat sheet for reading code:
| Operation | Returns | What it’s about | Result type |
|---|---|---|---|
|
string length | bytes, not runes | |
|
element by index | one byte | |
|
index and value | byte index, rune value | |
Mini demo (the important part is seeing that i in range is a byte index, not a “character number”):
package main
import "fmt"
func main() {
s := "καφε"
for i, r := range s {
fmt.Printf("i=%d r=%c\n", i, r)
// i=0 r=κ
// i=2 r=α
// i=4 r=φ
// i=6 r=ε
}
}
Why are the indexes 0,2,4,6? Because many non-ASCII letters in UTF‑8 take 2 bytes.
byte and rune: a “character” is not a byte
When you see the word “character”, it’s tempting to assume it’s something small and fixed-size. In ASCII it used to be true: 1 character = 1 byte. UTF‑8 does not guarantee that: one character can take 1, 2, 3, or 4 bytes. That’s why Go has two common “building blocks”:
byte is one byte (0..255).
rune is a number (essentially an int32) representing a Unicode code point.
Important: a “rune” is not always what the user perceives as “one character on the screen”. Some “human characters” can consist of multiple code points (for example, a letter + a combining diacritic). In this lecture we won’t dive into the depths of Unicode combining sequences, but let’s be honest: “one character as seen by the user” is a more complex concept than a rune. Our practical goal is simpler: don’t break UTF‑8 and normalize input correctly.
Conversions you’ll see most often:
- []byte(s) — get the string bytes (e.g., for low-level work);
- []rune(s) — get runes when you need “character-wise” indexing rather than “byte-wise”;
- string(runes) — build a string back.
Mini example: “carefully take the first rune”:
package main
import "fmt"
func main() {
s := "καφε"
rs := []rune(s)
fmt.Printf("%c\n", rs[0]) // κ
}
This does not mean you should always do it this way. But it is the correct approach if you truly need “the first rune” rather than “the first byte”.
2. Input normalization: whitespace and case
Why normalize data at the boundary
Normalization is not just “meh, spaces”. It’s a way to turn an unpredictable external world into predictable data inside your program. Ideally, business logic should operate like this: “I received a string already in a normal form, and now I don’t need to sprinkle hacks like TrimSpace and ToLower throughout the codebase”.
Imagine you’re writing a mini task app (let’s call it TaskBox). The user enters:
- add Buy milk
- ADD buy milk
- add buy milk
- Add BUY MILK
If you store the task “as entered”, then search, comparisons, deduplication, filters—everything becomes more complicated. If you store the task in normalized form, everything becomes straightforward. And this leads to an important engineering rule: normalize at the boundary, i.e., immediately when reading/creating the value, not “somewhere later”.
A typical simple normalization pipeline looks like this:
flowchart LR
A[raw input] --> B[TrimSpace]
B --> C[Fields]
C --> D[Join with a single space]
D --> E[ToLower or EqualFold for comparisons]
E --> F[valid value inside the program]
TrimSpace, Fields, Join: the basic anti-whitespace kit
When it comes to “dirty input”, the usual suspects are spaces, tabs, and newlines. And here Go provides a really nice minimal set of functions from strings that covers 80% of real-world needs without turning your code into a tragedy.
Important observations:
strings.TrimSpace(s) removes leading/trailing whitespace.
strings.Fields(s) splits on any whitespace and does not return empty tokens.
strings.Join(parts, " ") joins back with a single space.
Together this gives you a “collapse whitespace” effect: no matter how many spaces there were, inside you end up with exactly one.
Here is a small “reference” normalizer for a task title:
package main
import (
"fmt"
"strings"
)
func normalizeTitle(s string) string {
parts := strings.Fields(strings.TrimSpace(s))
return strings.Join(parts, " ")
}
func main() {
fmt.Println(normalizeTitle(" buy milk ")) // buy milk
}
Note: Fields already embodies the idea “who cares how many spaces and tabs there are”. That’s why strings.Split(s, " ") is usually worse for these tasks: it creates empty elements, and then you’re forced to filter them.
Case: ToLower and EqualFold
With case, there are two common situations: either you want to normalize data to a single form and store it, or you want to compare case-insensitively without changing anything.
For “normalize and store”, people usually use strings.ToLower. For “compare case-insensitively”, strings.EqualFold is often better, because it performs a Unicode-aware comparison (as much as is possible without full locale-specific behavior). We won’t discuss language-specific subtleties like Turkish I, but the idea matters: “just lower-case both strings and compare” is often good enough, but not always perfect.
In our training TaskBox, commands can be in any case: ADD, Add, add. So it’s convenient to normalize the command to lower-case.
package main
import (
"fmt"
"strings"
)
func normalizeCmd(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func main() {
fmt.Println(normalizeCmd(" ADD ")) // add
}
And here is how to express “two strings are equal ignoring case” more directly:
package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.EqualFold("καφε", "ΚΑΦΕ")) // true
}
3. Command parsing and search
Parsing a user command with Fields
Now let’s assemble this into a small piece of our app. We’ll assume we have a command line string (we got it somehow) and we want to parse it:
- an empty line is an error;
- the first word is the command;
- everything else is arguments.
The idea is simple: Fields gives us tokens without empty entries and without “extra spaces”.
package main
import (
"errors"
"strings"
)
func parseLine(line string) (string, []string, error) {
parts := strings.Fields(strings.TrimSpace(line))
if len(parts) == 0 {
return "", nil, errors.New("empty command")
}
cmd := strings.ToLower(parts[0])
return cmd, parts[1:], nil
}
The key here is the linear flow: normalize → check for empty → extract command. We don’t build “stairs” of else; we return an error immediately if the input is empty.
You can test it with a couple of strings:
package main
import "fmt"
func main() {
cmd, args, err := parseLine(" Add buy milk ")
fmt.Println(cmd, args, err) // add [buy milk] <nil>
}
See how convenient it is? Even if a person entered it “messily”, we still got a clean structure.
Normalizing a task title: why Fields+Join is better than TrimSpace
If you only do TrimSpace, you only clean up the edges. But what about the fact that inside there might be “three spaces in a row”? Then you print the task list—and it looks like the user types with their elbows. In search logic it hurts too: a query "buy milk" and a stored text "buy milk" become “different strings”, even though to a human it’s obviously the same.
So it’s convenient to store a title in a “collapsed” form. Let’s make a function that returns either a normalized title, or an error if it’s empty:
package main
import (
"errors"
"strings"
)
func newTitle(raw string) (string, error) {
parts := strings.Fields(strings.TrimSpace(raw))
if len(parts) == 0 {
return "", errors.New("title is empty")
}
return strings.Join(parts, " "), nil
}
And a mini check:
package main
import "fmt"
func main() {
t, err := newTitle(" ")
fmt.Printf("%q %v\n", t, err) // "" title is empty
}
Substring search: Contains without surprises
When you search for a substring, you almost always want “case-insensitive” behavior. The user types milk, while the task is stored as Buy milk. If you compare as-is, there will be no match. So we usually normalize both sides.
The simplest approach: convert both strings to lower-case and use strings.Contains. This is not perfect Unicode linguistics, but for learning tasks and most applied scenarios it’s fine.
package main
import (
"strings"
)
func containsIgnoreCase(s, sub string) bool {
s = strings.ToLower(s)
sub = strings.ToLower(sub)
return strings.Contains(s, sub)
}
If you want to demonstrate it with an example:
package main
import "fmt"
func main() {
fmt.Println(containsIgnoreCase("Buy milk", "MILK")) // true
}
Notice: if you store titles in normalized form in advance (for example, with “collapsed whitespace”), search becomes more stable with respect to spaces too. That’s a nice side effect of normalization: it helps not only with “pretty printing”, but also with “reliable searching”.
4. Practical string work: building and debugging
Building strings: why strings.Builder is not “optimization for the sake of optimization”
When you build a string from pieces, a beginner’s hand reaches for +=. And that’s fine: it works! But in a loop, += often creates lots of intermediate strings, and building becomes noticeably heavier (both time and memory). Sometimes it doesn’t matter. Sometimes it does. And sometimes it matters simply because code with Builder is more honest: “I am constructing a string”.
For our TaskBox, imagine we want to format a task as one line: number, status, and title. Builder looks neat here:
package main
import (
"fmt"
"strings"
)
func formatTask(i int, done bool, title string) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("%d) ", i))
if done {
b.WriteString("[x] ")
} else {
b.WriteString("[ ] ")
}
b.WriteString(title)
return b.String()
}
And an example:
package main
import "fmt"
func main() {
fmt.Println(formatTask(1, false, "buy milk")) // 1) [ ] buy milk
}
Yes, this uses Sprintf, which itself creates a string, but the idea of Builder is still clear. At this stage, readability and the technique matter more than microscopic optimization.
String diagnostics: printing with %q
String bugs are often invisible. Literally invisible: you have a trailing space and you don’t see it. Or there’s a \n, or a tab, or a non-breaking space. That’s why a very useful habit is: when debugging strings, print them as a “string literal”, i.e., with %q.
This turns “silent” spaces and control characters into visible \n, \t, etc.
package main
import "fmt"
func main() {
s := " milk \n"
fmt.Printf("%q\n", s) // " milk \n"
}
If you normalize input, then %q is a great way to verify that normalization removed exactly what you thought it removed.
Micro reminder about string constants
In Go there is a subtle but useful point: a string literal can be an “untyped constant” until the context forces it to become a string. In practice, it’s usually enough for a beginner to remember that const x = "hello" behaves a bit more freely than a variable, and the compiler may allow assignments/comparisons in a broader context.
Why bring this up in a lecture about strings and normalization? Because normalization often leads to small “reference values”: for example, commands like "add", "list", "done". And it’s convenient to keep them as constants: they don’t change, and the code becomes easier to read.
5. Common mistakes when working with strings and normalization
Mistake #1: treating len(s) as the number of characters.
This is one of the most common traps, because for ASCII strings len and “number of letters” often match. But as soon as you have non-ASCII letters or emoji, len counts bytes, and logic like “cut the first N characters” breaks. If you need to count runes, use for range or []rune.
Mistake #2: doing s[i] and thinking it’s a “character”.
String indexing returns a byte. If you then print it as %c, you may get “garbage” or a fragment of a UTF‑8 sequence. For “characters”, use range or []rune, and keep s[i] for cases where you consciously work with bytes (e.g., ASCII or a protocol).
Mistake #3: using strings.Split(s, " ") for user input.
If you have multiple spaces in a row, Split will create empty tokens, and then your code starts living in a world of “filter out empty elements”. For “split on whitespace”, strings.Fields is usually the correct choice: it drops empties right away and works with tabs and newlines too.
Mistake #4: normalizing data “somewhere later” instead of at the boundary.
When TrimSpace and ToLower are smeared across the code, you will inevitably forget normalization in some new place. Then come bugs like “why the ADD command doesn’t work”, “why search sometimes doesn’t find things”, “why identical tasks get duplicated”. Much more reliable: normalize once when reading/creating the value, and then work only with the normalized form.
Mistake #5: debugging strings without %q.
When a string hides a trailing space or an unexpected \n, plain printing via fmt.Println(s) shows a “nice picture” but hides the issue. Printing with %q makes the input “visible” and often saves hours. In programming terms: “turn on the lights and stop tripping over furniture”.
GO TO FULL VERSION