1. The string model: bytes and immutability
When you are just starting to program, a string feels like something obvious: “well, it’s text.” And it seems logical that “the first character of a string” is s[0], “the string length” is len(s), and “replace one letter” is “just assign by index.” In some languages that is almost how it works—until you run into non-ASCII text and spend an evening debugging “why did I get some weird garbage instead of a letter.”
Go’s approach is more honest and straightforward: a string is bytes, and the language does not pretend that a “character” is always equal to one byte. We will lock in a mental model that saves you from a whole class of strange bugs: from incorrect length to situations like “cut the string—got mojibake.”
What string is in Go
In the most down-to-earth terms, a value of type string stores some sequence of bytes. These bytes often represent text in UTF-8, but right now the “mechanics” matter more: a string is not an array of characters; it is an array of bytes, just packaged into a special type.
String immutability
The second part of the definition is almost more important than the first: a string is immutable. This means that after a string is created, you cannot change one “element” inside it. You can only create a new string from the old one.
This sounds like a limitation, but in practice it makes string behavior predictable. When you pass a string into a function, you can be sure the function will not “accidentally” overwrite its contents by index.
2. Length, indexing, and slicing
len(s) on a string: counting bytes, not “letters”
Let’s start with what is already familiar: len(s). Surprise (for a beginner): len for a string returns the number of bytes.
On ASCII text this usually matches the number of “visible characters,” so it looks like everything is fine. But as soon as the string contains characters that take more than one byte in UTF-8, len(s) becomes “larger than you expected.” And that is not a bug in len—it’s you counting “letters” in your head while the program counts bytes.
A mini example on “safe” ASCII:
package main
import "fmt"
func main() {
s := "Go"
fmt.Println(len(s)) // 2
}
And now an example that, for now, is simply an observation, without deep explanations:
package main
import "fmt"
func main() {
s := "Hi, 世界"
fmt.Println(len(s)) // will be larger than the number of "visible characters"
}
What matters here is not the exact answer, but the fact: len returns the size in bytes. That is why you cannot naively use len as “the number of letters in the text” if you are really working with a human language.
Indexing a string s[i]: you get a byte (i.e., a number)
Now the main character: the expression s[i].
When you write s[i], Go does not return “a string of length 1.” It returns one byte from this sequence—that is, a number in the range 0..255. The type of this expression is uint8 (and in everyday Go this is exactly byte).
Let’s look at it through fmt:
package main
import "fmt"
func main() {
s := "Go"
fmt.Println(s[0], s[1]) // 71 111
fmt.Printf("%T\n", s[0]) // uint8
}
Why do we get 71 and 111? Because these are the byte codes of "G" and "o" in ASCII.
If you want to “see the character,” you can print the byte using "%c" (as “a character by its code”). This works great for ASCII:
package main
import "fmt"
func main() {
s := "Go"
fmt.Printf("%c\n", s[0]) // G
fmt.Printf("%c\n", s[1]) // o
}
But keep in mind: you are printing one byte as if it were a character code. For ASCII that matches reality. For UTF-8 text—not always, because one “human character” may be encoded with multiple bytes.
Why you cannot change a string by index
After you see s[0], your hand may reach for the obvious:
s[0] = 'G'
And Go will rather strictly say “no.” Strings are immutable.
I’ll show it as a “commented-out forbidden thing” so the code still compiles:
package main
import "fmt"
func main() {
s := "go"
// s[0] = 'G' // not allowed: string is an immutable type
fmt.Println(s) // go
}
Why is Go designed this way? On the “human reason” level—to make strings safe and predictable values. On the “practical reason” level—because strings are often shared between different parts of a program, and the ability to mutate them “from the inside” would make life harder for the compiler, the runtime, and your future self debugging it on a Friday night.
How to “modify a string” correctly: build a new one
If you cannot edit a string “in place,” what should you do if you need to change, for example, the first letter? Create a new string.
The simplest approach, already available at this stage: take pieces and concatenate them.
package main
import "fmt"
func main() {
s := "go"
// take "G" and append the tail starting from byte 1
s = "G" + s[1:]
fmt.Println(s) // Go
}
Notice two things. First, we are not “changing s inside”—we assign a new string to s. Second, s[1:] is a slice expression on a string, and it also works by bytes.
String slices s[a:b]: also by bytes, also half-open
A string slice looks the same as a slice of a slice: s[a:b]. And the boundary rules are the same: we take the half-open interval [a, b), meaning a is inclusive and b is exclusive.
Very important: a and b are byte indices, not “letter positions.”
On ASCII strings everything looks “as expected”:
package main
import "fmt"
func main() {
s := "golang"
fmt.Println(s[:2]) // go
fmt.Println(s[2:]) // lang
fmt.Println(s[1:4]) // ola
}
But if the string contains characters encoded with multiple bytes, “cutting by bytes” can mean “splitting a character in half.” This is one of the reasons we keep repeating the word “bytes.”
3. Bounds and operation results
Bounds checking: s[i] can crash your program
There is one more thing that bites beginners pretty quickly: going out of bounds.
If i < 0 or i >= len(s), then s[i] triggers a runtime panic. The compiler will not save you, because i is often computed at runtime.
A mini example (demonstration):
package main
import "fmt"
func main() {
s := "Go"
i := 2
// fmt.Println(s[i]) // panic: index out of range
fmt.Println(len(s)) // 2
}
The right habit: before indexing a string by a variable index, make sure the index is valid. Early on you can do it with a simple if:
package main
import "fmt"
func main() {
s := "Go"
i := 1
if i >= 0 && i < len(s) {
fmt.Printf("%c\n", s[i]) // o
}
}
Micro table: what you get from different operations
Sometimes it helps to pin down “what an expression returns” in one place, so your brain stops improvising.
| Expression | What it is | Result type |
|---|---|---|
|
string length in bytes | |
|
one byte at index i | (i.e., ) |
|
substring by byte boundaries [a, b) | |
This follows directly from the fact that a string behaves like “a sequence of elements of type byte” when indexed.
4. Practice: normalize a command in a console app
Now let’s make a small practical detour: we will add a mini function to our learning console app (let’s call it Tasky) that “normalizes a command” by capitalizing the first letter. This is useful at least for nicer output (“add” → “Add”), and it also demonstrates perfectly: indexing gives you a byte, the string is immutable, so you have to build a new string.
Let’s be honest upfront: this will be an ASCII solution. With Cyrillic letters it will not work the way you might expect. And that’s fine—we are not diving into Unicode earlier than necessary.
Let’s start with a function “make the first letter uppercase if it’s Latin”:
package main
import "fmt"
func titleASCII(s string) string {
if len(s) == 0 {
return s
}
b := s[0] // byte
if b >= 'a' && b <= 'z' {
b = b - ('a' - 'A')
}
return string(b) + s[1:]
}
func main() {
fmt.Println(titleASCII("add")) // Add
fmt.Println(titleASCII("list")) // List
}
This one block contains several ideas.
First we check len(s) == 0, because otherwise s[0] will crash with index out of range. Then we take b := s[0]—that’s a byte. The comparisons b >= 'a' and b <= 'z' work because character literals in single quotes are numeric codes, and in ASCII the range 'a'..'z' is contiguous. Then we build a new string: string(b) (a length-1 string in ASCII) plus the tail s[1:].
To “change one letter,” we end up creating a new string. But everything is honest, predictable, and without hidden magic.
5. Diagnostics: print a string as bytes
Printing string bytes to verify contents
Sometimes a string looks fine, but you suspect it contains extra spaces, tabs, or some “invisible” bytes. Already now, even without the strings package and without reading full lines, you can diagnose a string as bytes.
Here is a short example: print each byte and its “character view” (correct for ASCII):
package main
import "fmt"
func main() {
s := "Go!"
for i := 0; i < len(s); i++ {
fmt.Printf("i=%d byte=%d char=%c\n", i, s[i], s[i])
// i=0 byte=71 char=G
// i=1 byte=111 char=o
// i=2 byte=33 char=!
}
}
This exercise really helps “burn in” the idea: a string is indexed by bytes, not by “characters.”
Diagram: “string as bytes”
Sometimes it is easier to see a picture once and stop arguing with reality.
flowchart LR
A["string: \"Go\""] --> B["len(s) = 2 bytes"]
A --> C["s[0] = 71 (byte)"]
A --> D["s[1] = 111 (byte)"]
A --> E["s[:1] = 'G' (string)"]
A --> F["s[1:] = 'o' (string)"]
This diagram emphasizes the main point: s[i] is a byte-number, while s[a:b] is a string again.
6. Common mistakes when treating strings as bytes
Mistake #1: assuming len(s) is “the number of characters.”
On ASCII text this is often “accidentally true,” so the mistake may not show up for a long time. But len counts bytes, and as soon as the string becomes “human text” (not only Latin), byte length and character count diverge. It’s better to keep in mind from the start: len is about bytes.
Mistake #2: expecting s[i] to return a “character” or a “length-1 string.”
Indexing a string returns a byte (uint8). If you then do fmt.Println(s[i]) and see a number—Go is not mocking you; you asked for a byte and it honestly came back. To display it “as a character,” you can temporarily use "%c", but the meaning is still byte-based.
Mistake #3: trying to modify a string by index.
A string is immutable, and an attempt like s[0] = 'X' simply will not compile. The correct style is to create a new string from parts: prefix, new fragment, suffix.
Mistake #4: forgetting about bounds and getting panic: index out of range.
s[0] on an empty string is a classic. Another classic is computing an index as len(s) and thinking “well, that’s the last one.” No—the last index is len(s) - 1. If the index comes from outside (user input, a calculation), it’s better to check first: 0 <= i && i < len(s).
Mistake #5: slicing a string s[a:b] and assuming “a and b are letter positions.”
A string slice works by bytes. On ASCII it is not noticeable, but on arbitrary text it is easy to get “broken” output. Even if you do not yet know how to work “by characters,” it is useful to at least remember that slicing is a byte-based half-open interval [a, b).
GO TO FULL VERSION