I ran into a small Go question while revisiting strings and Unicode. It looked like the kind of thing that should take a few seconds to answer:

package main

import "fmt"

func main() {
	s1 := "✍️"
	s2 := "πŸ‘¨β€πŸ’»"
	s3 := "πŸ˜†"

	fmt.Println(len(s1), len(s2), len(s3))
}

What does it print?

Most Go developers know the basic rule: len on a string returns its length in bytes. We also know that UTF-8 uses between one and four bytes to encode a Unicode code point, and many common emoji happen to use four.

So if you read the code too quickly, 4 4 4 looks like a reasonable answer.

The actual output is:

6 11 4

At first this looks like UTF-8 is doing something strange. It is not.

A single Unicode code point still takes at most four bytes in UTF-8. The interesting part is somewhere else: one emoji displayed on the screen does not have to be one Unicode code point.

Why Go len() returns 6, 11, and 4

A Go string is a sequence of bytes. It can contain arbitrary bytes, although normal Unicode text written directly in Go source code is encoded as UTF-8.

Because of that:

len(s)

returns the number of bytes, not the number of Unicode code points and not the number of characters a user sees.

The easiest value from the example is:

πŸ˜†

This emoji is the Unicode code point U+1F606.

It is one code point, and UTF-8 encodes it using four bytes:

fmt.Println(len("πŸ˜†")) // 4

This is probably where the useful but incomplete rule “an emoji takes four bytes” comes from.

Now consider:

✍️

It looks like one emoji, but internally it consists of two Unicode code points:

U+270D  WRITING HAND
U+FE0F  VARIATION SELECTOR-16

U+FE0F is a variation selector. Here it asks the renderer to use the emoji presentation of the preceding character.

Both code points require three bytes in UTF-8:

U+270D   3 bytes
U+FE0F   3 bytes

total    6 bytes

So:

fmt.Println(len("✍️")) // 6

The technologist emoji goes further:

πŸ‘¨β€πŸ’»

It consists of three code points:

U+1F468  MAN
U+200D   ZERO WIDTH JOINER
U+1F4BB  PERSONAL COMPUTER

U+200D is the Zero Width Joiner, usually called ZWJ. It is an invisible Unicode character used to join characters into a single rendered sequence.

Without the ZWJ:

πŸ‘¨πŸ’»

With it:

πŸ‘¨β€πŸ’»

The UTF-8 sizes are:

U+1F468   4 bytes
U+200D    3 bytes
U+1F4BB   4 bytes

total    11 bytes

So the original result is no longer that surprising:

✍️     6 bytes
πŸ‘¨β€πŸ’»    11 bytes
πŸ˜†     4 bytes

There is no eleven-byte UTF-8 code point here. There are three separate code points whose UTF-8 encodings take eleven bytes in total, and the renderer displays the sequence as one emoji.

Runes are Unicode code points, not visible characters

The next obvious idea in Go is to stop counting bytes and count runes instead.

In Go, rune is an alias for int32 and is used to represent Unicode code points.

For example:

package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	fmt.Println(utf8.RuneCountInString("✍️"))
	fmt.Println(utf8.RuneCountInString("πŸ‘¨β€πŸ’»"))
	fmt.Println(utf8.RuneCountInString("πŸ˜†"))
}

The output is:

2
3
1

The same strings can now be described in three different ways:

TextUTF-8 bytesRunesVisible emoji
✍️621
πŸ‘¨β€πŸ’»1131
πŸ˜†411

This is the part that is easy to miss even if you already know how len works in Go.

A rune is not necessarily what a user would call one character.

A for range loop makes the internal structure easy to inspect:

package main

import "fmt"

func main() {
	s := "πŸ‘¨β€πŸ’»"

	for i, r := range s {
		fmt.Printf("byte index: %d, rune: %U\n", i, r)
	}
}

Output:

byte index: 0, rune: U+1F468
byte index: 4, rune: U+200D
byte index: 7, rune: U+1F4BB

Notice that i is a byte offset.

The man starts at byte 0 and occupies four bytes. The ZWJ therefore starts at byte 4. It occupies three bytes, so the laptop starts at byte 7.

The complete string occupies eleven bytes.

Grapheme clusters: counting what the user sees

Unicode has another concept that is useful here: the grapheme cluster.

Roughly speaking, a grapheme cluster is what a user perceives as one character. A cluster may consist of one code point or several.

For our examples:

✍️     2 runes, 1 grapheme cluster
πŸ‘¨β€πŸ’»    3 runes, 1 grapheme cluster
πŸ˜†     1 rune,  1 grapheme cluster

The Go standard library gives us byte operations and UTF-8 rune operations, but it does not provide a general grapheme cluster counter.

If an application actually needs to count user-perceived characters, a Unicode segmentation library can be used. One option in Go is github.com/rivo/uniseg:

package main

import (
	"fmt"

	"github.com/rivo/uniseg"
)

func main() {
	fmt.Println(uniseg.GraphemeClusterCount("✍️"))
	fmt.Println(uniseg.GraphemeClusterCount("πŸ‘¨β€πŸ’»"))
	fmt.Println(uniseg.GraphemeClusterCount("πŸ˜†"))
}

The result is:

1
1
1

That is a different question from both len and utf8.RuneCountInString, which is why it produces a different answer.

Emoji sequences get more interesting with modifiers

A good example of how far this can go is:

πŸ‘©πŸΏβ€πŸ’»

That is one visible emoji: woman technologist with dark skin tone.

Internally it contains four code points:

U+1F469  WOMAN
U+1F3FF  EMOJI MODIFIER FITZPATRICK TYPE-6
U+200D   ZERO WIDTH JOINER
U+1F4BB  PERSONAL COMPUTER

Their UTF-8 representation takes:

U+1F469   4 bytes
U+1F3FF   4 bytes
U+200D    3 bytes
U+1F4BB   4 bytes

total    15 bytes

In Go:

package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "πŸ‘©πŸΏβ€πŸ’»"

	fmt.Println(len(s))
	fmt.Println(utf8.RuneCountInString(s))

	for i, r := range s {
		fmt.Printf("byte index: %d, rune: %U\n", i, r)
	}
}

The output is:

15
4
byte index: 0, rune: U+1F469
byte index: 4, rune: U+1F3FF
byte index: 8, rune: U+200D
byte index: 11, rune: U+1F4BB

One thing on the screen. Four runes. Fifteen bytes.

This sequence is also a good way to understand some apparently strange behavior in text editors.

Visually, πŸ‘©πŸΏβ€πŸ’» looks atomic. The underlying text is not. If you put the cursor at the end and start erasing from right to left, the exact intermediate states depend on the editor, the cursor position, and which Unicode unit the editor uses for deletion.

With the cursor at the end, this is normally a Backspace operation. One possible sequence can look like this:

"πŸ‘©πŸΏβ€πŸ’»" -> one Backspace -> "πŸ‘©πŸΏβ€" -> "πŸ‘©πŸΏ" -> "πŸ‘©οΏ½" -> "πŸ‘©" -> ""

Another possible sequence is:

"πŸ‘©πŸΏβ€πŸ’»" -> "πŸ‘©πŸΏπŸ’»" -> "πŸ‘©οΏ½πŸ’»" -> "πŸ‘©πŸ’»" -> "πŸ’»" -> ""

The first sequence removes the laptop before the joiner and the skin-tone modifier. The "πŸ‘©πŸΏβ€" step still contains a trailing ZWJ, so it may look exactly like "πŸ‘©πŸΏ" on screen even though the strings are different.

The second sequence exposes a different internal path: removing the ZWJ first turns the combined emoji into separate πŸ‘©πŸΏ and πŸ’» emoji.

The οΏ½ deserves a separate note. It is U+FFFD REPLACEMENT CHARACTER, and it is not part of the original emoji. It can appear when an editor or rendering pipeline encounters an ill-formed or incomplete code-unit/byte sequence, for example a broken surrogate pair or a truncated multibyte sequence. A Unicode-aware editor may instead remove a complete code point or even the whole grapheme cluster, so these exact deletion steps are not guaranteed across editors.

That variability is the point: what looks like one character can contain several code points, modifiers, and invisible joiners, and text-editing behavior depends on which boundary the editor chooses to treat as one deletion unit.

A quick comparison with C#

I also work with C#, and it is a useful comparison because .NET strings expose a different unit by default.

Take the same values:

string s1 = "✍️";
string s2 = "πŸ‘¨β€πŸ’»";
string s3 = "πŸ˜†";

Console.WriteLine(s1.Length);
Console.WriteLine(s2.Length);
Console.WriteLine(s3.Length);

The output is:

2
5
2

These numbers are different from Go, but C# is not counting visible characters either.

string.Length in C# returns the number of UTF-16 code units. A C# char represents one 16-bit UTF-16 code unit.

Code points in the Basic Multilingual Plane can be represented by one UTF-16 code unit. Code points above U+FFFF require a surrogate pair, which means two char values.

For the writing hand sequence:

U+270D   1 char
U+FE0F   1 char

total    2 char

For the technologist:

U+1F468   2 char
U+200D    1 char
U+1F4BB   2 char

total    5 char

And even πŸ˜†, despite being one Unicode code point, requires two UTF-16 code units:

U+1F606   2 char

This is why the following C# code does not compile:

char c = 'πŸ˜†';

πŸ˜† requires a surrogate pair, while one C# char stores exactly one UTF-16 code unit.

Modern .NET also has System.Text.Rune, which is much closer in purpose to Go’s rune:

using System.Text;

Rune r = new Rune(0x1F606);

Console.WriteLine(r); // πŸ˜†

But the same Unicode distinction remains.

One Rune represents one Unicode scalar value. It cannot represent an entire multi-code-point sequence such as:

✍️
πŸ‘¨β€πŸ’»
πŸ‘©πŸΏβ€πŸ’»

Changing languages does not remove the problem. It mostly changes which unit the default string API exposes.

What this means for Go string handling

For plenty of backend code, len(s) is exactly what you want.

If you are checking payload size, allocating buffers, working with protocols, or dealing with encoded data, bytes are the correct unit.

If you need Unicode code points, utf8.RuneCountInString, []rune(s), and for range are the relevant tools.

The ambiguity appears when a requirement says something like:

Username must be at most 20 characters.

What does “20 characters” mean?

Twenty bytes would make ASCII and emoji behave very differently.

Twenty runes is usually closer to what was intended, but πŸ‘©πŸΏβ€πŸ’» would still count as four even though the user sees one emoji.

If that limit is supposed to match user-perceived characters, grapheme clusters are the relevant unit.

The same distinction matters for truncation and editing.

Cutting a string at an arbitrary byte offset can split a UTF-8 encoding and produce invalid UTF-8. Converting to []rune prevents that particular problem, but it can still split one grapheme cluster in the middle.

That is how code can accidentally keep the woman while dropping the laptop, remove a skin tone modifier, or leave an invisible joiner behind.

There are three different questions hidden behind the word “character”:

How many bytes are there?
How many Unicode code points are there?
How many characters does the user perceive?

In Go, len answers the first.

Runes answer the second.

Grapheme segmentation answers the third.

The original 6 11 4 result is not an exception to UTF-8’s one-to-four-byte rule.

UTF-8 never broke that rule.

We were just counting a different thing than the one displayed on the screen.