Last Updated: May 17, 2026
A string is how you store text in Go: a product code like "BOOK-01", a customer name, an email, an order status. This lesson covers what a string actually is in Go, how to declare one, the difference between regular and raw literals, and the rules around indexing, slicing, and comparing strings. The big surprise is that a Go string is a sequence of bytes, not characters, and that distinction shapes almost everything you do with text.
A Go string is an immutable, read-only sequence of bytes. That's a precise definition, and every word in it matters. Sequence of bytes means the string holds raw 8-bit values, not characters in any high-level sense. Read-only means you can look at those bytes but you can't change them in place. Immutable means once a string value exists, its contents will never change for the lifetime of the program.
The string "BOOK-01" is seven bytes long: B, O, O, K, -, 0, 1. Each character in this string happens to be an ASCII character, which is exactly one byte, so the byte count and the character count line up. That convenient match is the source of a lot of confusion, because the moment your string contains non-ASCII characters (accented letters, currency symbols, emoji), the two counts stop agreeing.
Internally, a Go string is a small two-word header: a pointer to the underlying bytes and a length. You don't usually think about that header, but it explains why string operations like slicing and passing strings to functions are cheap: only the header gets copied, not the bytes themselves.
A string value is just a header that points at byte storage. The header is small and cheap to copy. The byte storage itself is shared and read-only, which is why two variables can hold the "same" string without duplicating its memory.
The simplest way to declare a string is with :=, which lets the compiler figure out the type:
You can also be explicit about the type with var, which is useful when you want to declare a string without giving it a value right away:
The zero value of a string is "", the empty string. That's a real string value with length 0, not nil. Strings in Go are never nil. There's no such thing as nil for a string variable, and you don't need to check for it. The only "absence" state for a string is the empty string.
This is different from slices and maps, which do have a nil value distinct from their empty form. For strings, an uninitialized variable and an explicitly empty string are the exact same value:
Both variables hold the empty string. There's no way to tell them apart, because there's nothing to tell apart. This makes string handling refreshingly simple: check s == "" or len(s) == 0 when you want to know if a string is empty, and you've covered every case.
Go has two ways to write a string literal in source code, and they handle special characters differently.
A double-quoted literal is what you've already seen. It interprets escape sequences like \n (newline), \t (tab), \\ (backslash), and \" (quote):
Each \n turns into a real newline byte (the byte with value 10). The literal has to fit on one line of source code, and you can't include a literal newline inside the quotes.
A raw string literal is wrapped in backticks instead of double quotes. It takes whatever bytes you put between the backticks and uses them as-is, with no escape processing:
The raw literal spans three lines of source code, and the line breaks in the source become real newline bytes in the string. No escape sequences are interpreted: a \n inside backticks is two literal characters, a backslash followed by an n. The only thing you can't put inside a raw literal is a backtick itself.
Compare the two:
The cooked version produced a real newline. The raw version printed the backslash and the n literally, because raw strings don't process escapes.
Raw strings are the right choice when the text already contains characters that would otherwise need escaping. Regular expressions, Windows file paths, JSON snippets, and SQL queries with quotes all read more cleanly as raw strings:
Writing that SQL with double quotes would force you to escape every " inside the string, and writing the regex pattern with double quotes would force you to double up every backslash. Backticks sidestep both problems.
Here's a side-by-side summary of when each form makes sense:
| Form | Syntax | Escapes? | Multiline? | Good For |
|---|---|---|---|---|
| Double-quoted | "..." | Yes (\n, \t, \", etc.) | No (use \n) | Most strings, especially short ones |
| Raw | `...` | No | Yes | Regex, SQL, JSON, file paths, multi-line templates |
Both forms produce the exact same type. A double-quoted string and a backtick string are both string values, indistinguishable once they exist. The choice is purely about how readable the source code is.
You cannot change a byte inside an existing string. Once you have a string value, its contents are fixed forever. Trying to assign to an index of a string is a compile error, not a runtime crash:
The compiler stops the program before it ever runs. The error message is wordy, but the point is simple: a string index expression like productCode[0] is not assignable. There's no way to write a single byte into an existing string.
To "change" a string, you build a new one and replace the variable:
The variable productCode now refers to a new string. The original "book-01" is still immutable, but the variable has been pointed at a different value. The variable is mutable, the string itself is not.
Immutability has real benefits. Two variables can share the same underlying bytes safely, because neither can modify them. Passing a string to a function never risks the function changing the caller's data. String comparison can stop early at the first differing byte without worrying about the bytes changing under it. The trade-off is that any "edit" to a string requires building a new one, which means allocation. Use strings.Builder as the standard tool for stitching many small pieces of text together without piling up allocations.
Cost: every "modification" to a string allocates a new string. For one or two edits this is fine. For thousands of edits in a loop, use strings.Builder.
len Returns Bytes, Not CharactersThe built-in len function on a string returns the number of bytes in the string, not the number of characters. For pure ASCII text this is the same number, which is why most introductory examples look fine. As soon as the string contains anything outside the ASCII range, the byte count and the character count diverge.
The first two strings have one byte per character, so the lengths match what you'd expect. The third one shows the catch: "€9.99" looks like 5 characters but takes 6 bytes. The euro sign € is a non-ASCII character that takes 3 bytes in Go's internal encoding. The digits and the period are 1 byte each, so 3 + 1 + 1 + 1 + 1 = 6.
This is a real source of bugs in code that tries to truncate names, count characters in user input, or build fixed-width displays. If you write len(customerName) and treat the result as a character count, your program will be subtly wrong for any user whose name uses accented letters.
For now, the rule to internalize is this: len(s) is always a byte count, and only equals the character count when every character is ASCII.
When you index into a string with s[i], you get a single byte value, not a character and not a one-character string. The byte type in Go is an alias for uint8, so the value is a number between 0 and 255.
Indexing productCode[0] returns 66, which is the byte value of the ASCII letter B. The type is uint8, confirming that byte is just a name for that type. There's no implicit conversion to a printable character.
To see the character, you have to convert. Two common ways are formatting with %c (which interprets the byte as a Unicode code point) and converting to a string explicitly:
The number 66 and the character B are two views of the same byte. Which one you want depends on what you're doing.
Indexing past the end of a string is a runtime panic, similar to indexing past the end of a slice:
The runtime catches this and stops the program with a clear message. There's no silent return of a zero byte, and no "undefined behavior". Always make sure i < len(s) before reading s[i].
Indexing into a string that contains non-ASCII characters is where things get interesting. Because indexing reads bytes and a non-ASCII character takes more than one byte, s[0] for a string like "€9.99" returns only the first byte of the euro sign, not the whole symbol. That byte on its own isn't meaningful as a character.
Cost: s[i] is O(1) by byte position, never by character position. There's no built-in "give me the i-th character" indexing in Go. If you need character-level access, you iterate or convert to a slice of runes.
+ and +=The + operator joins two strings into a new one. Both operands must be strings:
Each + builds a new string by allocating fresh memory and copying both sides into it. The original strings are unchanged, since strings are immutable. The variable message ends up pointing at the newly allocated string.
+= works the same way, just shorthand for "take the current value, append this, and reassign":
Every += allocates a new string and discards the old one. For three lines, that's fine. For thousands of small additions inside a loop, the cost adds up: each iteration allocates a string roughly the size of everything that came before, so the total work grows as the square of the input size.
For five items, the loop is fine. For five million items, the same pattern would be painfully slow because each += allocates and copies the entire running result. The fix is strings.Builder. For now, know that + and += are correct in every case, just not the right tool when the loop count is large.
Cost: s += part in a loop is O(n^2) over the total bytes written. Use strings.Builder for building a string from many small pieces, or strings.Join from the strings package when you have all the pieces in a slice already.
You can concatenate string literals across multiple lines by ending each fragment with +:
The trailing + tells the parser that the expression continues on the next line. This is a Go syntax requirement, not a style choice. Putting the + at the start of the next line wouldn't work because Go's automatic semicolon insertion would end the statement at the end of the previous line. A raw literal with backticks is the cleaner alternative when the text itself is multi-line and you don't mind real newlines being part of the value.
Strings support all six comparison operators: ==, !=, <, <=, >, >=. Equality (== and !=) compares the bytes for an exact match. Ordering (<, <=, >, >=) compares lexicographically, byte by byte, using the unsigned byte values.
Equality says a and b hold the same bytes. The ordering check says a comes before c because the first byte where they differ (position 6) has 1 (byte value 49) on one side and 2 (byte value 50) on the other.
The byte-level comparison has two consequences worth knowing about. First, comparison is case-sensitive, because uppercase and lowercase letters have different byte values:
"book" and "Book" aren't equal, and "book" doesn't come before "Book" because lowercase ASCII letters (97-122) sort after uppercase ASCII letters (65-90) by byte value. For a case-insensitive compare, use strings.EqualFold.
Second, ordering on non-ASCII text follows byte order, not any human notion of alphabetical order. So "é" (a 2-byte character) doesn't sort right next to "e". For most application code that's fine, because you rarely sort on raw strings anyway. For user-facing sorting, you reach for the golang.org/x/text/collate package, which is outside this section's scope.
The comparison decides for each status whether its first byte is less than the byte for p (which is 112). "delivered" starts with d (100), "cancelled" starts with c (99), both less than 112. "shipped" starts with s (115) and "pending" starts with p (112), both at or after.
Cost: == and != are O(n) in the worst case but stop at the first differing byte, so they're fast in practice. Ordering comparisons have the same characteristic: byte-by-byte, exit early.
Slicing a string with s[i:j] returns a new string consisting of the bytes from index i up to but not including index j. The slice indices are byte positions, same as everywhere else in string handling.
productCode[0:4] takes bytes 0 through 3, which is "BOOK". productCode[5:7] takes bytes 5 and 6, which is "01". The hyphen at index 4 is skipped.
Like slice expressions on slices, you can omit either index. Leaving off the start defaults to 0, leaving off the end defaults to len(s):
productCode[:5] is the first 5 bytes, productCode[6:] is everything from byte 6 to the end, and productCode[:] is the whole string.
Substring slicing is cheap. The new string shares the underlying bytes with the original, the same way a slice expression on a slice shares the backing array. No bytes are copied. Only a new header (pointer + length) is created.
Both productCode and prefix point into the same backing storage. The headers differ (different start offset and length), but the bytes themselves are shared and read-only. Since strings are immutable, this sharing is always safe: neither variable can modify what the other sees.
That's a nice property for short-lived substrings. There's a sharper edge to be aware of, though: as long as a substring exists, the entire original backing storage stays alive. If you slice a 10-byte prefix out of a 1 GB string and hold onto the prefix, the runtime can't reclaim the 1 GB until both the original and the substring are unreachable. For most code this doesn't matter. For long-lived substrings of huge strings, you can force a copy with something like string([]byte(prefix)).
Cost: s[i:j] is O(1) and allocates no bytes. The trade-off is that the substring keeps the full original byte storage alive until the substring itself is garbage-collected.
Slicing out of bounds is a runtime panic, just like indexing out of bounds:
The runtime catches it and stops the program. Always make sure your slice indices satisfy 0 <= i <= j <= len(s).
There's one more quirk to know about. Because slicing operates on bytes, slicing a string that contains non-ASCII characters can cut a multi-byte character in half. The result is a string containing an invalid byte sequence, which prints as a replacement character (often a ? or a � symbol depending on the renderer). You won't get an error or a panic, just garbled output.
For now, when you slice strings of ASCII content (product codes, order IDs, status names, plain English text), substring slicing behaves exactly as you'd expect.
Here's a small program that exercises most of what's been covered. It uses double-quoted and raw literals, indexes a string, builds a new string with concatenation, slices out a substring, compares two codes, and confirms that the original is unchanged.
Every operation in this program either reads bytes from an existing string or builds a new string. Nothing modifies a string in place, because nothing can.
"", and strings are never nil.s[0] = 'X' is a compile error). To "change" a string, build a new one and reassign the variable.len(s) returns the byte count, not the character count. The two match only when every character is ASCII.s[i] returns a byte (alias for uint8), not a character or a one-character string. Use %c formatting or string(b) to view it as text.+ and += build new strings and are correct everywhere, but += in a tight loop is O(n^2); use strings.Builder for that pattern.s[i:j] returns a substring that shares the original byte storage. It's O(1) and allocates nothing, but keeps the full original alive.The next lesson, UTF-8 and Runes, explains how non-ASCII text actually fits into a byte sequence and introduces the rune type, which is Go's way of representing a single character regardless of how many bytes it takes to encode.