OneBug All write-ups

Go ·

Go len(string) counts bytes, not the characters you see

len("cafe") with an accented e returns 5, not 4, so a four-character limit rejects a valid name. Go strings are UTF-8 bytes. Here is the rune-count fix.

Test yourself before reading - the write-up below gives it away.

Spot the bug Go
1
package main
2
 
3
import (
4
	"fmt"
5
	"unicode/utf8"
6
)
7
 
8
// Summarize a display name: its character count and whether it fits the 4-char limit.
9
func summarize(name string) (int, bool) {
10
	chars := utf8.RuneCountInString(name)
11
	fits := len(name) <= 4
12
	return chars, fits
13
}
14
 
15
func main() {
16
	fmt.Println(summarize("café"))
17
	// expected: 4 true, actual: 4 false  (!)
18
}

A signup form kept turning away one tester and nobody could see why. Her chosen handle was four letters, well under the limit the code checked, and every ASCII name in the test suite sailed through. The reject fired on "café", and to the validator that name was one character too long.

Here is the check, stripped to the line that misfires:

package main

import (
	"fmt"
	"unicode/utf8"
)

// Summarize a display name: its character count and whether it fits the 4-char limit.
func summarize(name string) (int, bool) {
	chars := utf8.RuneCountInString(name)
	fits := len(name) <= 4
	return chars, fits
}

func main() {
	fmt.Println(summarize("café"))
	// expected: 4 true, actual: 4 false  (!)
}

Why does len return the wrong string length in Go?

Line 11 is the bug. len(name) on a Go string returns the number of bytes, not characters, and a Go string is a read-only slice of UTF-8 bytes. The accented é encodes as two bytes, so len("café") is 5, the <= 4 test is false, and a valid four-character name is reported as not fitting. Count characters the way line 10 already does, with utf8.RuneCountInString(name) from unicode/utf8.

A Go string is UTF-8 bytes, and len measures the byte count

len never promised you characters. On a string it returns the length of the underlying byte sequence, and it does so in O(1) by reading a field, not by scanning. Go source is UTF-8 and string literals inherit that encoding, so every character outside the ASCII range occupies more than one byte: two for most Latin accents and Greek, three across the common CJK ranges, four for many emoji. The é in this literal is 0xC3 0xA9, two bytes for one visible letter, which is the entire gap between the 5 the machine counts and the 4 the eye counts.

The Go blog lays this out directly in Strings, bytes, runes and characters in Go: a string holds arbitrary bytes, and "character" is a fuzzy word the language deliberately avoids. What Go gives you instead is the rune, an alias for int32 holding one Unicode code point.

Line 10 is the line that draws the eye, and it is correct. utf8.RuneCountInString(name) is the unfamiliar, Unicode-aware call, exactly the shape of thing that looks like it might fumble an accent, and it returns the right 4. The mistake is the plain-looking line directly beneath it: line 11 compares len(name), a byte count, against a character budget, as if the code had not just counted characters correctly one line up. The bug is not the scary line; it is the ordinary one next to it.

The reason this survives review is arithmetic, not luck. For any ASCII string, one character is exactly one byte, so len and the true character count are identical. Every "alice", "bob", "retry_queue" in a fixture agrees with itself. The check is genuinely correct for the whole ASCII range and stays correct right up until the first name with an accent, the first Cyrillic handle, the first CJK display name, the first emoji. The data that breaks it is the data no English-only test seed contains.

What a byte count does downstream

Follow the wrong number past the rejection. Suppose the form, instead of rejecting the name, tried to be helpful and trim it to fit: name[:4]. Slicing a string in Go slices bytes, so name[:4] on "café" keeps caf plus the first byte of é alone. That half a rune is not valid UTF-8. Printed, it renders as caf followed by a replacement glyph; stored and read back through a byte-by-byte loop, name[i], it emerges as mojibake like café, the classic sign that someone walked UTF-8 one byte at a time. A length check that was only ever off by one has now corrupted the value it touched, and the corruption travels into the database looking like a data-entry problem rather than a slicing bug.

The fix, and the fix that looks right but is not

Count runes, not bytes:

import "unicode/utf8"

utf8.RuneCountInString(name) // 4 for "café"

utf8.RuneCountInString walks the bytes and counts code points in O(n), with no allocation. len([]rune(name)) returns the same 4, but it first allocates a whole []rune just to measure it, so prefer the former when all you need is the count.

One honest caveat, because it is the thing most "just count runes" advice omits: a rune count is still not always what a human would count. A grapheme, one thing the reader perceives as a character, can be several runes. A flag emoji is two runes; an emoji with a skin-tone modifier is two or more; the letter in "é" written as a plain e followed by a combining acute accent is two runes for one visible mark. RuneCountInString returns 2 there and a person returns 1. If you need true user-perceived character counts, a rune count is closer than a byte count but still wrong, and you reach for a grapheme-cluster library such as rivo/uniseg.

The tempting wrong fix is to reach for an index loop and read characters off one at a time. for i := 0; i < len(s); i++ { _ = s[i] } indexes bytes, so on any multi-byte rune it hands you fragments, not letters. Reaching for for i, r := range s is the right instinct, because ranging a string decodes runes into r, but read the first variable carefully: i is the byte offset of each rune, not a rune ordinal, so i jumps from 3 to 5 across the é and never takes the value 4. People count the loop iterations and get the right answer, then try to use i as a character index and land right back in byte space.

Find it in your codebase

The smell is len of a string standing in for a character count, usually next to a limit or a trim. Sweep for the shape:

grep -rnE 'len\([a-zA-Z_]+\)' --include=*.go .

Most hits are legitimate; len on a byte slice or a genuine byte budget is exactly right. Read each one and ask a single question: is the argument a string, and is the number being treated as "how many characters the user typed"? Validation limits, truncation to fit a column, and anything comparing against a human-facing maximum are where a byte count masquerades as a character count. The same suspicion applies to every s[:n] and s[i] on a string that might hold non-ASCII input.

Related bugs

This is the Go family of "the loop is not doing what it looks like": the same surprise powers how a for-range loop hands you a copy, not the struct, where the value you mutate is not the one in the slice. It also rhymes with Go's wider memory model for the byte-backed trio of strings, slices, and arrays, on display in the reslice that quietly shares its backing array. More Go traps live on the Go bug hub, and the full set is at every bug write-up.

Next time a length check rejects a name it should accept, print len(name) beside utf8.RuneCountInString(name) and see which one the accent moved. If they disagree, your limit has been counting bytes the whole time.