In Go, a string is an immutable sequence of bytes, usually holding UTF-8 text. A byte is an alias for uint8, one raw byte. A rune is an alias for int32, one Unicode code point. That's why len("สวัสดี") returns 18 and not 6: len counts bytes, and each Thai character takes three bytes in UTF-8. Once the rune/byte/string distinction clicks, most string bugs in Go become obvious.
This guide starts with Go's basic data types, constants and iota, then covers how strings, bytes and runes relate, how to iterate over text correctly, and how to use the strings, strconv and strings.Builder tools you'll reach for every day.
Go basic data types
Go is statically typed, and every variable has a type fixed at compile time:
| Type | Size | Zero value | Notes |
|---|---|---|---|
bool | 1 byte | false | |
int, uint | 32 or 64 bits | 0 | Platform-dependent, 64-bit on modern servers |
int8…int64, uint8…uint64 | fixed | 0 | Use when size matters (binary formats, DB columns) |
byte | 8 bits | 0 | Alias for uint8 |
rune | 32 bits | 0 | Alias for int32, a Unicode code point |
float32, float64 | 32 / 64 bits | 0 | Default to float64 |
string | header + data | "" | Immutable bytes |
Declaring and converting:
var count int // 0
name := "Vectorkub" // type inferred as string
ratio := 0.75 // float64
total := count + int(ratio*100) // explicit conversion required
var big int64 = 1 << 40
small := int32(big) // compiles, but silently truncates to 0
fmt.Println(name, total, small) // Vectorkub 75 0Go never converts numeric types implicitly. int + int64 is a compile error, and you must write the conversion yourself. Converting a float to an integer truncates toward zero, and converting to a smaller integer type keeps only the low bits, so check ranges before narrowing.
Integer overflow at runtime wraps around silently. A uint8 holding 255 becomes 0 after ++. Overflow in a constant expression, on the other hand, is a compile error.
Constants and iota
Constants are evaluated at compile time and can't change:
const Pi = 3.14159
const MaxRetries = 3Untyped constants like these have arbitrary precision until they're used. const huge = 1 << 100 is legal, and huge >> 98 is the constant 4. You only get an error if you try to store huge itself in an int.
Enumerations with iota
Inside a const block, iota starts at 0 and increases by one on each line. Lines without an expression repeat the previous one:
type Status int
const (
StatusPending Status = iota // 0
StatusPaid // 1
StatusShipped // 2
StatusCancelled // 3
)
func (s Status) String() string {
switch s {
case StatusPending:
return "pending"
case StatusPaid:
return "paid"
case StatusShipped:
return "shipped"
case StatusCancelled:
return "cancelled"
}
return fmt.Sprintf("Status(%d)", int(s))
}Giving the enum its own type (Status) stops callers from passing a random int. The String() method makes fmt.Println(StatusPaid) print paid. For larger enums, the stringer tool from golang.org/x/tools generates this method for you.
iota also works in expressions:
const (
_ = iota // skip 0
KB = 1 << (10 * iota) // 1 << 10
MB // 1 << 20
GB // 1 << 30
)
type Perm uint8
const (
Read Perm = 1 << iota // 1
Write // 2
Exec // 4
)The bit-flag pattern lets you combine values with | and test them with &: p&Write != 0.
How Go strings store bytes
A string is a read-only view over bytes. Indexing returns a byte, and you can't assign through an index:
s := "Go"
fmt.Println(s[0]) // 71, the byte for 'G'
// s[0] = 'g' // compile error: cannot assign to s[0]
s = "go" // fine: the variable now points to a different stringImmutability is what makes strings safe to share. Slicing (s[1:4]) and assignment don't copy the data, and no goroutine can change it under you. The cost is that any modification creates a new string.
Byte vs rune in Go
Go source files are UTF-8, and string literals are stored as UTF-8. ASCII characters take one byte, most Latin accents take two, and Thai, Chinese and Japanese take three:
s := "สวัสดี"
fmt.Println(len(s)) // 18 bytes
fmt.Println(utf8.RuneCountInString(s)) // 6 runes
fmt.Println(s[0]) // 224: the first byte of 'ส', not a characters[0] gives you a byte in the middle of a multi-byte sequence, which is almost never what you want for text. For code points, use runes:
r := []rune(s)
fmt.Println(len(r)) // 6
fmt.Println(string(r[0])) // ส
fmt.Printf("%U\n", r[0]) // U+0E2AOne more subtlety matters for Thai. A rune is a code point, not necessarily a visible character. In "สวัสดี", the vowel marks ั and ี are separate runes that combine with the consonant before them. So 6 runes render as 4 visible characters. If you need to count or cut what users see, for example to truncate a display name, use a grapheme-aware library such as github.com/rivo/uniseg.
byte | rune | |
|---|---|---|
| Alias of | uint8 | int32 |
| Represents | One raw byte | One Unicode code point |
| Literal | 'a' assigned to a byte | 'ก' (default type of a char literal) |
| Use for | Binary data, ASCII protocols, I/O | Text processing, Unicode-aware logic |
Ranging over a string
for range over a string decodes UTF-8 for you. The index is the byte offset, and the value is a rune:
for i, r := range "héllo" {
fmt.Printf("%d:%c ", i, r)
}
// 0:h 1:é 3:l 4:l 5:oNote the jump from 1 to 3, because é takes two bytes. A classic for i := 0; i < len(s); i++ loop walks bytes instead, which breaks non-ASCII text. Invalid UTF-8 bytes come out of range as utf8.RuneError (U+FFFD), so check utf8.ValidString when you're processing untrusted input.
Converting between string, []byte and []rune
s := "hello"
b := []byte(s) // copies the bytes
b[0] = 'H'
s2 := string(b) // copies again: "Hello"
r := []rune("กขค")
r[0] = 'ง'
fmt.Println(string(r)) // งขคEvery conversion copies, because strings are immutable and slices aren't. That's cheap for small values but shows up in profiles in hot loops. Many APIs accept []byte directly (bytes, io.Writer, json.Unmarshal), so stay in one form when you can.
A common mistake is string(65). It produces "A", the character for code point 65, not "65". go vet flags it. To turn a number into its decimal text, use strconv.
The strings package
The strings package covers most everyday text work. The bytes package has the same functions for []byte.
s := " Order-1042,paid,Bangkok "
strings.TrimSpace(s) // "Order-1042,paid,Bangkok"
strings.Split("a,b,c", ",") // ["a" "b" "c"]
strings.Fields(" a b\tc ") // ["a" "b" "c"], splits on any whitespace
strings.Join([]string{"a", "b"}, "-") // "a-b"
strings.Contains(s, "paid") // true
strings.HasPrefix("Order-1042", "Order-") // true
strings.ReplaceAll("a-b-c", "-", "/") // "a/b/c"
strings.ToUpper("go") // "GO"
strings.EqualFold("Go", "GO") // true, case-insensitive compare
strings.Index("chicken", "ken") // 4 (byte offset), -1 if not found
strings.Repeat("=", 10) // "=========="
fmt.Println(strings.Cut("key=value", "=")) // key value trueTwo traps worth knowing:
TrimvsTrimPrefix.strings.Trim(s, "abc")removes any of the charactersa,b,cfrom both ends. To remove an exact prefix or suffix, useTrimPrefixorTrimSuffix.strings.Titleis deprecated. It doesn't handle Unicode word boundaries correctly. Usegolang.org/x/text/casesinstead.
Parsing and formatting with strconv
strconv converts between strings and numbers or booleans, and it returns errors instead of guessing:
n, err := strconv.Atoi("42") // int 42
if err != nil {
return fmt.Errorf("invalid quantity: %w", err)
}
id, err := strconv.ParseInt("9000000000", 10, 64) // base 10, int64
price, err := strconv.ParseFloat("19.95", 64)
ok, err := strconv.ParseBool("true") // accepts 1, t, T, TRUE, true, True, 0, f...
strconv.Itoa(42) // "42"
strconv.FormatInt(255, 16) // "ff"
strconv.FormatFloat(19.95, 'f', 2, 64) // "19.95"
fmt.Println(n, id, price, ok) // 42 9000000000 19.95 truefmt.Sprintf("%d", n) also works, but strconv is faster and makes the intent clear. Always handle the error from parsing functions, since user input will eventually contain "12abc" or an empty string.
Building strings with strings.Builder
Because strings are immutable, s += x in a loop allocates a new string each time and copies everything so far. For large loops that's quadratic work.
strings.Builder appends to an internal buffer and produces the final string once:
func CSVLine(fields []string) string {
var b strings.Builder
b.Grow(64) // optional: pre-allocate if you can estimate the size
for i, f := range fields {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(f)
}
return b.String()
}It implements io.Writer, so fmt.Fprintf(&b, "%d items", n) works too. Don't copy a Builder after writing to it: pass a pointer. For joining a slice you already have, strings.Join is simpler and just as fast. If you suspect string building is a bottleneck, confirm it with a CPU and allocation profile first. Profiling Go with pprof shows how.
FAQ
How many bytes is a Thai character in Go?
Three. Thai code points (U+0E00 to U+0E7F) are encoded as three bytes in UTF-8, so len returns three per character, including vowel and tone marks.
What is the difference between byte and rune in Go?
A byte (uint8) is one raw byte. A rune (int32) is one Unicode code point, which can take one to four bytes in UTF-8.
How do I convert an int to a string in Go?
Use strconv.Itoa(n) or strconv.FormatInt(n, 10). Don't use string(n), which returns the character for that code point.
How do I reverse a string in Go?
Convert to []rune, reverse the slice, and convert back. For text with combining marks, like Thai vowels, reverse by grapheme cluster instead or the marks will attach to the wrong consonant.
Why can't I change a character in a Go string?
Strings are immutable. Convert to []byte or []rune, change the slice, then convert back to a new string.
Checklist for working with Go text
- Remember that
len(s)counts bytes. Useutf8.RuneCountInStringfor code points. - Iterate with
for rangewhen you care about characters, not bytes. - Give enums their own type with
iotaand aString()method. - Use
strconvfor number conversion and handle every parse error. - Replace
+=in loops withstrings.Builderorstrings.Join. - Validate UTF-8 on untrusted input, and count graphemes when you truncate user-visible text.
Strings are one of Go's core value types. Go slices, maps and pointers covers the others, and Go structs, methods and interfaces shows how to give your own types behavior. If you're building a Go backend that handles Thai and English text and want help getting it right, Vectorkub can help.
