Skip to content
Software Atlas

Text & Encodings

Bytes are just numbers. How ASCII, Unicode and UTF-8 turn them into text, why a string's length depends on who's counting, how Base64 sneaks bytes through text, and when JSON should give way to a binary format.

⏱ ~45 min read14 sections9 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA computer can't store the letter A. It stores the number 65 and everyone agrees that 65 means A. Every text bug in this topic comes from two programs not agreeing on that table.

Files, network packets and memory hold only bytes: numbers from 0 to 255. A byte has no idea whether it's part of a photo, a price or a poem. Text appears only when a program reads the bytes using an agreed mapping from numbers to characters. That mapping is called an Encoding.

Modern text goes through up to four layers. Each one answers a different question, and most confusion comes from mixing them up:

One word, five layers
What a person sees §07
Which characters are on the screen?
café
Graphemes §07
Where does each user-perceived character start and end?
café
Code points (Unicode) §04
Which number is each character?
U+0063U+0061U+0066U+00E9
Bytes (UTF-8) §05
Which bytes store those numbers?
636166c3 a9
Text-safe (Base64) §09
How do bytes travel through text-only channels?
Y2Fmw6k=
Each arrow is a conversion with its own rules. Most text bugs happen at one specific arrow: the section number says where it's covered.

What you'll be able to do

1

Read the bytes

See 63 61 66 c3 a9 and know it's "café" in UTF-8. Spot why it shows up as "café" somewhere else.
2

Count correctly

Know why "😀".length is 2 in JavaScript, len("😀") is 4 in Go, and a user sees 1 character.
3

Pick a format

Know when to send Base64, when JSON is fine, and when a binary format saves real money.
Foundations§02

Bytes are just numbers: ASCII

In one sentenceA Byte is a number from 0 to 255. ASCII is the oldest surviving agreement about which of those numbers mean which English characters.

Bits, bytes and the two ways to write them

A Bit is a single 0 or 1. Eight bits make a byte, and 8 bits can be arranged in 2⁸ = 256 different ways, so a byte holds a number from 0 to 255.

You'll see the same byte written three ways. They're all the same number:

DecimalBinary (8 bits)Hex (2 digits)Note
720100100048each hex digit = exactly 4 bits
25511111111ffthe biggest byte
00000000000the smallest

Hex counts to 15 in one digit: 0–9, then a = 10 up to f = 15. So e9 = 14 × 16 + 9 = 233. Hex is popular because it's short and lines up with bits: 4 is 0100, 8 is 1000, so 48 is 0100 1000. Tools that dump raw bytes almost always print hex.

ASCII: 128 characters, 7 bits

In 1963 American engineers published ASCII: a table of 128 codes (0–127), enough for English letters, digits, punctuation and some machine instructions. 128 values need only 7 bits, so every ASCII character fits in a byte with the top bit set to 0.

CodesWhat's there
0–31, 127Control characters: 9 tab, 10 newline (\n), 13 carriage return (\r)
32space
48–57the digits 0–9
65–90A–Z
97–122a–z
the restpunctuation: ! is 33, / is 47, @ is 64, { is 123

Notice two design tricks. The digit 7 is code 55, not 7: the character "7" and the number 7 are different things. And lowercase is always uppercase + 32 (A = 65, a = 97), a single bit's difference, which made case conversion cheap on 1960s hardware.

Seeing the bytes of a string

Here's "Hi!" as bytes in both languages, printed as decimal, hex and binary:

main.go
package main

import "fmt"

func main() {
    b := []byte("Hi!")       // the string's bytes
    fmt.Println(b)           // as decimal numbers
    fmt.Printf("% x\n", b)   // as hex, space-separated
    fmt.Printf("%08b\n", b)  // as 8-bit binary

    back := string([]byte{72, 105, 33}) // numbers → text
    fmt.Println(back)
}
// Output:
// [72 105 33]
// 48 69 21
// [01001000 01101001 00100001]
// Hi!

From a terminal you can dump any file's bytes with od (on most systems xxd or hexdump -C work too):

$ printf 'Hi!' | od -An -tx1
 48 69 21

ASCII covers English and nothing else: no é, no ñ, no ü, no 中. The other 128 values a byte can hold (128–255) were left unused, and that's where the trouble starts.

Foundations§03

Code pages and mojibake

In one sentenceThe bytes are almost never broken. Mojibake (garbled text) happens when a program reads correct bytes with the wrong table.

One byte, many meanings

ASCII used codes 0–127. Every country then filled 128–255 with its own letters, producing dozens of 8-bit tables called code pages. They all agree with ASCII for the first 128 codes and disagree about the rest:

ByteWindows-1252 (Western Europe)Windows-1251 (Cyrillic)CP437 (old IBM PC)
0x41 (65)AAA
0xE9 (233)éйΘ
0xC3 (195)ÃГ├

A file is just bytes. Nothing inside it says which table was used. So a French email with byte 0xE9 arrives in Moscow as "й", and nobody did anything "wrong" except assume.

The classic: café

That garbage has a name: Mojibake.

Today almost everything writes UTF-8 (§05 explains it). In UTF-8, "é" takes two bytes: c3 a9. A program that still assumes Windows-1252 reads those two bytes as two separate characters: c3 → "Ã" and a9 → "©". That's why you see café.

It can get worse. If someone saves that garbage back as UTF-8 and it gets misread again, each wrong character grows again:

main.go
package main

import (
    "fmt"

    "golang.org/x/text/encoding/charmap" // go get golang.org/x/text
)

func main() {
    b := []byte("café")         // written as UTF-8
    fmt.Printf("% x\n", b)

    // The reader wrongly assumes Windows-1252:
    wrong, _ := charmap.Windows1252.NewDecoder().Bytes(b)
    fmt.Println(string(wrong))

    // The garbage is saved as UTF-8 again, then misread again:
    twice, _ := charmap.Windows1252.NewDecoder().Bytes(wrong)
    fmt.Println(string(twice))
}
// Output:
// 63 61 66 c3 a9
// café
// café

Try it yourself. Choose how the text was written and how it's read, then watch the same bytes turn into different text:

Mojibake lab
Same bytes, two decoder rings. Choose how the text is written and how it's read.
1 · written as
636166c3a9
5 bytes. Highlighted bytes are above 127, where the tables disagree.
2 · read as
café
✗ not what was written
Each multi-byte UTF-8 character is read as several Windows-1252 characters: c3 a9 (é) turns into à + ©. Try “misread twice”.

Recognizing mojibake in the wild

You seeWhat probably happened
é, è, ü, ’UTF-8 bytes read as Windows-1252 / Latin-1 (’ is a curly apostrophe ’)
éthe same mistake, twice
� (a diamond with a question mark)bytes that aren't valid UTF-8 read as UTF-8, e.g. a Latin-1 file (§11)
? in place of letterstext was converted to an encoding that has no such letter, and the converter gave up
Foundations§04

Unicode: one number for every character

In one sentenceUnicode gives every character in every writing system one number, called a code point. It says nothing yet about bytes.

Code pages failed because there were dozens of small tables. In the early 1990s the Unicode Consortium started building one table for all of them: Latin, Greek, Arabic, Chinese, Hindi, maths symbols, music notes, and later emoji.

Code points

Each entry's number is its Code point, written U+ followed by at least four hex digits:

CharacterCode pointDecimalName in the standard
AU+004165LATIN CAPITAL LETTER A
éU+00E9233LATIN SMALL LETTER E WITH ACUTE
€U+20AC8364EURO SIGN
中U+4E2D20013CJK UNIFIED IDEOGRAPH-4E2D
😀U+1F600128512GRINNING FACE

Two compatibility choices made adoption painless. The first 128 code points are ASCII (A is 65 in both), and the first 256 match Latin-1, so é is 233 in both too.

How big is the table?

Code points run from U+0000 to U+10FFFF: room for 1,114,112 values. About 173,000 are assigned so far (172,808 in Unicode 18.0, released September 2026; as of October 2026 that's the latest version); the rest is reserved for the future. The space is split into 17 planes of 65,536 each:

17 planes × 65,536 code points
plane 0
BMP
almost every living script, CJK, symbols
plane 1
SMP
emoji, historic scripts, music, maths
plane 2–3
SIP / TIP
rare Chinese characters
plane 4–13
unassigned
empty, reserved for the future
plane 14
SSP
special tags and variation selectors
plane 15–16
private use
yours to define; no standard meaning
Widths are not to scale (planes 4–13 alone are 10 planes). Plane 0 runs U+0000–U+FFFF; plane 1 runs U+10000–U+1FFFF, and so on up to U+10FFFF.

The first plane, the Basic Multilingual Plane, holds nearly every character in everyday use. Emoji live mostly in plane 1. That detail looks like trivia now, but it's exactly why "😀".length is 2 in JavaScript (§06).

Asking for code points in code

Both languages can walk a string one code point at a time:

main.go
package main

import "fmt"

func main() {
    for _, r := range "Aé€😀" { // range decodes one code point (a rune) at a time
        fmt.Printf("%c  %U  decimal %d\n", r, r, r)
    }
}
// Output:
// A  U+0041  decimal 65
// é  U+00E9  decimal 233
// €  U+20AC  decimal 8364
// 😀  U+1F600  decimal 128512

Unicode is a table, not a file format

This is the distinction that clears up most confusion:

✕ Common mix-up
"This file is in Unicode."
(Which bytes? Unicode alone
doesn't say.)
✓ Two separate steps
character → code point   (Unicode: é → U+00E9)
code point → bytes       (an encoding: UTF-8 → c3 a9,
                          UTF-16BE → 00 e9)

Unicode decides which number. An encoding such as UTF-8 or UTF-16 decides which bytes store that number. The same code point becomes different bytes in different encodings, which is why "save as Unicode" in old Windows dialogs (it meant UTF-16) produced files other tools couldn't read. Here's one string stored three ways (§05 and §06 explain UTF-8 and UTF-16 in detail; for now just notice the code points stay the same):

Same text, three byte streams
One list of code points, three ways to store it. Click a character to light up its bytes everywhere.
UTF-8
1–4 bytes each, no byte order · 9 B
c3a9e282acf09f9880
UTF-16LE
2 or 4 bytes each · 8 B
e900ac203dd800de
UTF-32LE
always 4 bytes · 12 B
e9000000ac20000000f60100
The code points on top never change; only the byte layout does. UTF-16 and UTF-32 store each unit as a 2- or 4-byte number, so they come in little-endian (smallest byte first, what most CPUs use) and big-endian versions: flip the toggle and watch their bytes reverse while UTF-8 stays put.
Practitioner§05

UTF-8: from code point to bytes

In one sentenceUTF-8 stores each code point in 1 to 4 bytes: ASCII stays one byte, and the first bits of every byte say what role it plays.

The problem it solves

The biggest code point, U+10FFFF, needs 21 bits. The simplest encoding, UTF-32, spends a fixed 4 bytes on every character. That works, but English text becomes 4× bigger than ASCII, and every old ASCII file would need converting.

UTF-8 (designed in 1992 by Ken Thompson and Rob Pike, who later co-created Go) uses as few bytes as each character needs:

Code pointsBytesBit pattern (x = bits of the code point)Payload bits
U+0000–U+007F10xxxxxxx7
U+0080–U+07FF2110xxxxx 10xxxxxx11
U+0800–U+FFFF31110xxxx 10xxxxxx 10xxxxxx16
U+10000–U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx21

The fixed bits at the start of each byte are the whole trick. The first byte of a character, the lead byte, announces the length: 0 = 1 byte, 110 = 2, 1110 = 3, 11110 = 4. Every following byte is a continuation byte and starts with 10.

Worked example: é

  1. é is U+00E9 = 233. In binary that's 11101001 (8 bits).
  2. 8 bits is too many for the 1-byte form (7 payload bits), so use the 2-byte form, which has 11 slots. Pad with zeros on the left: 000 1110 1001.
  3. Cut from the right: the last 6 bits 101001 go in the continuation byte, the remaining 5 bits 00011 go in the lead byte.
  4. Lead byte: 110 + 00011 = 11000011 = 0xC3. Continuation byte: 10 + 101001 = 10101001 = 0xA9.

So é is c3 a9, the two bytes that turned into "é" in §03. Step through it, then try € and 😀:

Encode one character in UTF-8
Pick a character and step through the bit shuffling.
The four forms
1 byte · U+0000–U+007F
0xxxxxxx
2 bytes · U+0080–U+07FF
110xxxxx 10xxxxxx
3 bytes · U+0800–U+FFFF
1110xxxx 10xxxxxx 10xxxxxx
4 bytes · U+10000–U+10FFFF
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
BitsU+00E9
code point in binary
11101001
é is code point U+00E9 = 233 in decimal = 11101001 in binary: 8 bits.
step 1 / 6 · code point
main.go
package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    for _, s := range []string{"A", "é", "€", "😀"} {
        r, _ := utf8.DecodeRuneInString(s)
        fmt.Printf("%s  %U  %d byte(s): % x\n", s, r, len(s), s)
    }
}
// Output:
// A  U+0041  1 byte(s): 41
// é  U+00E9  2 byte(s): c3 a9
// €  U+20AC  3 byte(s): e2 82 ac
// 😀  U+1F600  4 byte(s): f0 9f 98 80

Why UTF-8 won

1. ASCII is unchanged. Every ASCII file is already a valid UTF-8 file, byte for byte, and English text costs nothing extra. Old tools that only look for /, , or newline keep working, because those bytes (all below 128) never appear inside a multi-byte character: lead and continuation bytes always have the top bit set.

2. It's Self-synchronizing. Lead bytes and continuation bytes look different, so a program dropped anywhere in the middle of a stream can find the next character start by skipping bytes that begin with 10:

Jumping into the middle of “hé😀!”
byte 0
68
01101000
ASCII
byte 1
c3
11000011
lead
byte 2
a9
10101001
continuation
byte 3
f0
11110000
lead
byte 4 ← start
9f
10011111
continuation
skip
byte 5
98
10011000
continuation
skip
byte 6
80
10000000
continuation
skip
byte 7
21
00100001
ASCII
start ✓
h = 68é = c3 a9😀 = f0 9f 98 80! = 21
A reader that starts at byte 4 lands inside 😀. It skips the bytes that start with 10 (continuation bytes) and resynchronizes at 21, the next character start. Nothing before or after is damaged.

That's why one corrupted byte damages only one character, and why searching UTF-8 text for a UTF-8 word can never match half a character.

3. No byte-order question. UTF-8 works one byte at a time, so it has no Endianness problem: the bytes are the same on every CPU. UTF-16 and UTF-32 store 2- and 4-byte numbers, so they come in big- and little-endian versions.

4. Sorting the bytes sorts the code points. Comparing two UTF-8 strings byte by byte gives the same order as comparing their code points. That isn't the alphabetical order a human expects (§12), but it's consistent everywhere.

Today about 98% of websites use UTF-8 (approximate, from web surveys in 2025), as do Go source files, JSON (required by RFC 8259), and, by convention, Linux and macOS file names.

Practitioner§06

UTF-16, JavaScript strings and Go strings

In one sentenceA JavaScript string is a list of 16-bit code units. A Go string is a list of bytes. Neither is a list of characters, so length means something different in each.

UTF-16 in one minute

Unicode started out as a fixed 16-bit code called UCS-2, back when 65,536 characters seemed enough for everyone. When that ran out, UTF-16 (1996) became the patch: it keeps the 16-bit units and adds a trick for everything above. It stores text in 16-bit code units:

  • A code point in the Basic Multilingual Plane (up to U+FFFF) is stored as one code unit with the same value: é → 00E9.
  • A code point above U+FFFF, like most emoji, doesn't fit in 16 bits. It's split across two code units called a Surrogate pair.

Unicode reserved the range D800–DFFF for exactly this: no character will ever have those code points, so a code unit in that range is always half of a pair. Here's how 😀 is split:

Splitting 😀 (U+1F600) into a surrogate pair
1 · the code point0x1F600 (too big for 16 bits)
2 · subtract 0x100000xF600 — always fits in 20 bits
3 · write as 20 bits, split 10 | 100000111101 1000000000
4 · add 0xD800 / 0xDC000x03D + 0xD800 · 0x200 + 0xDC00
5 · two code unitsD83D high surrogateDE00 low surrogate
Decoders reverse it: take the low 10 bits of each half, glue them together and add 0x10000 back.

JavaScript (1995), Java and the Windows API all adopted 16-bit strings in the UCS-2 days, before emoji existed, and kept them for compatibility. That's why their strings are UTF-16 code units today.

JavaScript: length counts code units

units.mjs
const s = "hi😀";
console.log(s.length);                         // UTF-16 code units
console.log(s.charCodeAt(2).toString(16), s.charCodeAt(3).toString(16));
console.log(s.codePointAt(2).toString(16));    // the whole code point
console.log([...s].length);                    // code points
console.log(JSON.stringify(s.slice(0, 3)));    // cuts the emoji in half
// Output:
// 4
// d83d de00
// 1f600
// 3
// "hi\ud83d"

What the JavaScript lines show:

  • length is 4: h, i, and the two halves of 😀.
  • charCodeAt(i) returns one code unit; codePointAt(i) notices a pair starting at i and returns the full code point.
  • [...s] (spread) and for...of iterate by code point, so they keep the emoji whole.
  • slice, substring, s[i] and split("") work on code units and can leave a lone surrogate (Lone surrogate): half an emoji that renders as � and isn't valid text. That's how a "truncate to 100 characters" helper corrupts emoji.

Go: a string is bytes

In Go, a string is a read-only sequence of bytes: it behaves like a []byte you can't change (no append, no assigning to s[i]). By convention, and always for string literals, those bytes are UTF-8.

main.go
package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "hé😀"
    fmt.Println(len(s))                    // bytes
    fmt.Println(s[1], s[2])                // indexing gives single bytes
    fmt.Println(utf8.RuneCountInString(s)) // code points
    fmt.Println(len([]rune(s)))            // same, by converting

    for i, r := range s { // i = byte offset, r = decoded rune
        fmt.Printf("%d:%c ", i, r)
    }
    fmt.Println()
    fmt.Printf("%q\n", s[:2]) // slicing by bytes can cut a character in half
}
// Output:
// 7
// 195 169
// 3
// 3
// 0:h 1:é 3:😀
// "h\xc3"
  • len(s) counts bytes: h (1) + é (2) + 😀 (4) = 7.
  • s[1] is a single byte (195 = 0xC3, the lead byte of é), not a character.
  • for i, r := range s decodes UTF-8 as it goes: r is a Rune (code point) and i is the byte offset where it starts. Offsets jump 0, 1, 3 because é took two bytes.
  • []rune(s) converts to a slice of code points; utf8.RuneCountInString(s) counts them without allocating.
  • s[:2] slices by bytes and here cuts é in half, leaving the lone byte \xc3.

See it all at once

Type anything below. Each card is one code point: its UTF-8 bytes in binary (the colored prefix bits from §05) and its UTF-16 code units. Try an emoji and watch it turn into a surrogate pair.

UTF-8 / UTF-16 inspector
One card per code point. Pink = lead-byte prefix, violet = continuation prefix, cyan = bits of the code point.
UTF-8 bytes
7
Go len(s)
UTF-16 units
4
JS s.length
code points
3
[...s].length
hU+0068
UTF-8 · 1 byte
0110100068
UTF-16 · 1 unit
0068
éU+00E9
UTF-8 · 2 bytes
11000011c3
10101001a9
UTF-16 · 1 unit
00E9
😀U+1F600
UTF-8 · 4 bytes
11110000f0
100111119f
1001100098
1000000080
UTF-16 · 2 units
D83D highDE00 low
You want…JavaScriptGo
UTF-8 byte countnew TextEncoder().encode(s).lengthlen(s)
code point count[...s].lengthutf8.RuneCountInString(s)
loop over code pointsfor (const ch of s)for i, r := range s
what length / len returnsUTF-16 code unitsbytes
safe "first N code points"[...s].slice(0, n).join("")string([]rune(s)[:n]) (check n ≤ the rune count first)
Practitioner§07

Graphemes: the length is a lie

In one sentenceWhat a reader sees as one character is a grapheme cluster, and it can be made of one code point or a dozen.

Characters built from pieces

Unicode lets you combine code points. Three common cases:

  • Accents. é can be the single code point U+00E9, or e followed by U+0301, a Combining mark that puts an acute accent on the letter before it. On screen they look identical.
  • Flags. There's no "Vietnam flag" code point. 🇻🇳 is two special letters, 🇻 (U+1F1FB) and 🇳 (U+1F1F3), which fonts draw as a flag when they appear together.
  • Emoji sequences. 👍🏽 is 👍 plus a skin-tone modifier (U+1F3FD). 👨‍👩‍👧 is man, woman and girl glued together by two invisible zero-width joiners (U+200D): five code points.

The unit a human perceives as one character is a Grapheme cluster. It's what your cursor jumps over and what Backspace deletes (mostly; editors differ).

Four honest answers to "how long is it?"

StringUTF-8 bytesUTF-16 code units (JS .length)Code pointsGraphemes
hello5555
café (é as U+00E9)5444
café (e + U+0301)6554
😀4211
🇻🇳8421
👨‍👩‍👧18851

None of these numbers is wrong. They answer different questions. Pick presets or type your own:

The length is a lie
Four correct answers to “how long is this string?”, computed live in your browser.
UTF-8 bytes
18
Go len(s) · storage limits
UTF-16 units
8
JS s.length · maxlength
code points
5
[...s].length · Go runes
graphemes
…
Intl.Segmenter · what users see
Code points (graphemes load in a moment)
👨
U+1F4684B · 2u
‍
U+200Dzero-width joiner
👩
U+1F4694B · 2u
‍
U+200Dzero-width joiner
👧
U+1F4674B · 2u
Under each code point: its UTF-8 bytes (B) and UTF-16 units (u), or its role if it's an invisible helper.

Counting graphemes in code

Unicode publishes the rules for where graphemes start and end (they change a little each year as new emoji arrive). JavaScript has them built in as Intl.Segmenter. Go's standard library doesn't, so use the widely used github.com/rivo/uniseg package:

main.go
package main

import (
    "fmt"
    "unicode/utf8"

    "github.com/rivo/uniseg" // go get github.com/rivo/uniseg
)

func main() {
    for _, s := range []string{"cafe\u0301", "🇻🇳", "👨‍👩‍👧"} {
        fmt.Printf("%s bytes=%d runes=%d graphemes=%d\n",
            s, len(s), utf8.RuneCountInString(s), uniseg.GraphemeClusterCount(s))
    }
}
// Output (each café is e + U+0301):
// café bytes=6 runes=5 graphemes=4
// 🇻🇳 bytes=8 runes=2 graphemes=1
// 👨‍👩‍👧 bytes=18 runes=5 graphemes=1

Which length should I use?

QuestionCountWhy
Will it fit in this database column or network buffer?bytes (in the column's encoding)storage limits are in bytes
What does a JS API or a DOM maxlength attribute check?UTF-16 unitsthat's what .length returns
"Max 280 characters" shown to a user, truncating a preview, moving a cursorgraphemes (in the UI)that's what a person counts; the server enforces a byte cap too (see the callout below)
Indexing into a Go string you got from rangebyte offsetsrange hands you byte offsets

Code points are rarely the right answer on their own. They're the unit Unicode's rules are written in, but users don't see them and storage isn't measured in them.

Drag the cut point and compare the three ways of cutting:

Truncation slicer
Cut “Hi 👨‍👩‍👧 café” at N. Red marks broken text: half a surrogate pair, a dangling joiner, or half a UTF-8 character.
s.slice(0, 8)
JS · 8 UTF-16 units
Hi👨ZWJ👩
s[:8]
Go · 8 bytes
Hi👨\xe2
grapheme-safe
Intl.Segmenter / uniseg · whole graphemes in ≤ 8 bytes
loading…
Try N = 4, 6 and 8 (inside the family emoji) and N = 26 (inside the é). The first two rows cut wherever N lands; only the last one steps back to a boundary a reader would recognize.
Practitioner§08

Normalization and case: when é !== é

In one sentenceThe same visible text can be stored as different code points. Normalize both sides to one form before you compare, search or use text as a key.

Same picture, different bytes

In §07, é could be one code point (U+00E9) or two (e + U+0301). Which one you get depends on where the text came from: macOS file names have historically used the two-code-point form, most keyboards on Windows and Linux produce the one-code-point form, and copy-paste carries whatever the source used.

To === and ==, those are simply different strings:

main.go
package main

import (
    "fmt"

    "golang.org/x/text/unicode/norm" // go get golang.org/x/text
)

func main() {
    a := "caf\u00e9"  // é as ONE code point (U+00E9)
    b := "cafe\u0301" // e + combining acute accent (U+0301)

    fmt.Println(a, b, a == b)
    fmt.Println(len(a), len(b)) // bytes
    fmt.Println(norm.NFC.String(a) == norm.NFC.String(b))
    fmt.Println(len(norm.NFD.String(a)))

    fmt.Println(norm.NFKC.String("file ① Hi"))
}
// Output (the second café is e + U+0301):
// café café false
// 5 6
// true
// 6
// file 1 Hi

The four normalization forms

Unicode normalization rewrites text into one standard form, so equal-looking text gets equal code points:

FormWhat it doesé becomesUse it for
NFCcompose: combine letter + accent into one code point where one existsU+00E9storing and comparing text (the web's default)
NFDdecompose: split into base letter + combining markse + U+0301stripping accents (decompose, then drop the marks)
NFKCNFC, plus fold "compatibility" look-alikes into plain charactersU+00E9usernames, identifiers, search keys
NFKDNFD, plus the same foldinge + U+0301search indexes

The K forms (compatibility normalization) go further: the ligature fi becomes f + i, ① becomes 1, full-width H (a wide Latin letter used when typing Chinese or Japanese) becomes H. That's great for matching what a user meant, but it changes meaning (x² becomes x2), so don't apply it to documents you store. Explore each form:

Normalization lab
Pick a string and see its code points in each normalization form. Highlighted rows differ from the original.
original
as typed / pasted
éU+0065U+0301
NFC
compose
éU+00E9
NFD
decompose
éU+0065U+0301
NFKC
compose + fold look-alikes
éU+00E9
NFKD
decompose + fold look-alikes
éU+0065U+0301
Two strings are “the same” for comparison only if they're equal after normalizing both to the same form. The two é presets differ as typed, but have identical NFC (and identical NFD). The K forms also fold fi, ① and full-width letters into plain ones, which is why they suit usernames but not documents. One surprise: the Å preset changes even under plain NFC. Some code points are exact duplicates kept for old standards (U+212B ANGSTROM SIGN), and every form swaps them for the standard letter (U+00C5).

Case-insensitive comparison is harder than toLowerCase()

Lowercasing both sides works for English and breaks elsewhere:

  • German ß uppercases to SS: two letters from one. JavaScript's "straße".toUpperCase() gives "STRASSE", but Go's strings.ToUpper keeps ß (it only does one-to-one mappings), giving "STRAßE".
  • Turkish has a dotted İ/i and a dotless I/ı. "TITLE".toLocaleLowerCase("tr") is "tıtle", so the same code gives different results depending on the user's language setting.

The right tool is Case folding, a mapping designed for comparison, not display:

main.go
package main

import (
    "fmt"
    "strings"

    "golang.org/x/text/cases"
)

func main() {
    fmt.Println(strings.EqualFold("Straße", "STRASSE")) // simple folding only

    fold := cases.Fold() // full Unicode case folding
    fmt.Println(fold.String("Straße") == fold.String("STRASSE"))
}
// Output:
// false
// true

Building a comparison key you can store

localeCompare compares two strings, but it can't give you a value to store in a unique column. For that you need a key function. Unicode's recipe (called NFKC_Casefold) is: normalize with NFKC, case-fold, then normalize with NFKC again, because folding can produce characters that need composing.

key.go
// Exact: full Unicode case folding from golang.org/x/text.
func Key(s string) string {
    s = norm.NFKC.String(s)
    s = cases.Fold().String(s)
    return norm.NFKC.String(s)
}

// Key("Straße") == Key("STRASSE") → true

If the key must be exact, build it in one place on the server (Go's cases.Fold(), or a library implementing NFKC_Casefold) and never recompute it differently in the browser.

Practitioner§09

Base64: bytes through text-only pipes

In one sentenceBase64 writes any bytes using only 64 boring, safe characters, so binary data can travel through places that only accept text. It costs about 33% more space and hides nothing.

Why it exists

Many channels were built for text and mangle anything else. Email (SMTP) was designed for 7-bit ASCII lines. JSON strings can't hold raw bytes. HTTP headers and URLs have characters that mean something special. A raw image byte like 0x00 or 0x0A (newline) would be cut off, rewritten or misread.

So we re-encode the bytes using characters that survive everywhere: A–Z, a–z, 0–9, + and /. That's 64 characters, and 64 = 2⁶, so each character carries exactly 6 bits.

Where you've seen itExample
email attachmentslines of iVBORw0KGgo… inside the message
data URLs<img src="data:image/png;base64,iVBORw0…">
HTTP Basic authAuthorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l
JWTseyJhbGciOi… (each part is base64url)
bytes inside JSON{"avatar": "3q2+7wAB"} (§10)

The mechanism: 3 bytes → 4 characters

Bytes have 8 bits and Base64 characters carry 6. The smallest chunk where both line up is 24 bits: 3 bytes in, 4 characters out. For each group of 3 bytes:

  1. Write the 3 bytes as 24 bits in a row.
  2. Cut those 24 bits into four 6-bit pieces.
  3. Each piece is a number from 0 to 63. Look it up in the alphabet A…Z a…z 0…9 + / (0 = A, 26 = a, 52 = 0, 62 = +, 63 = /).

When the input doesn't divide into groups of 3, the last group is filled up with zero bits, and the characters that carry no real data are written as =. That's Padding: one = means the last group had 2 real bytes, == means it had 1. Step through Man, then Ma and M:

Base64, 3 bytes at a time
Pick an input (or type up to 12 bytes) and step from bytes to characters.
group 13 bytes
'M'
77
'a'
97
'n'
110
Base64 works on bytes, so text is first encoded as UTF-8: 3 bytes, split into groups of 3.
step 1 / 5 · text → bytes
main.go
package main

import (
    "encoding/base64"
    "fmt"
)

func main() {
    for _, s := range []string{"Man", "Ma", "M"} {
        fmt.Println(s, "→", base64.StdEncoding.EncodeToString([]byte(s)))
    }

    raw := []byte{0xfb, 0xff, 0xbf} // bytes that hit the + and / characters
    fmt.Println(base64.StdEncoding.EncodeToString(raw))    // standard
    fmt.Println(base64.RawURLEncoding.EncodeToString(raw)) // URL-safe, no padding

    back, err := base64.StdEncoding.DecodeString("Y2Fmw6k=")
    fmt.Println(string(back), err)
}
// Output:
// Man → TWFu
// Ma → TWE=
// M → TQ==
// +/+/
// -_-_
// café <nil>

base64url: the web-safe variant

+ and / cause trouble in URLs (/ separates path segments, + can be read as a space in query strings) and = separates keys from values. Base64url swaps + → - and / → _, and usually drops the = padding, since the decoder can work out the length. JWTs, URL tokens and file names use it. A decoder for one alphabet will reject or misread the other, so check which one an API expects.

The browser trap: btoa() is not UTF-8

Browsers have btoa() (bytes to ASCII) and atob(), but they're from the 1990s and treat each UTF-16 code unit as one byte:

btoa.js
btoa("Man");            // "TWFu"
btoa("café");           // "Y2Fm6Q==", the Latin-1 bytes, not UTF-8's "Y2Fmw6k="
btoa("😀");             // throws InvalidCharacterError: code unit 0xD83D is bigger than a byte

// Correct: make UTF-8 bytes first, then turn each byte into one character for btoa
const bytes = new TextEncoder().encode("😀");
btoa(String.fromCharCode(...bytes)); // "8J+YgA=="

Newer runtimes add Uint8Array.prototype.toBase64() and Uint8Array.fromBase64(), which work on bytes directly. As of October 2026 they're in all major browsers (since Chrome 140, September 2025; Firefox and Safari had them earlier) and in Node 25+, but not in Node 22 or 24. In every current Node version, Buffer works; its "base64url" option needs Node 15.7 / 14.18 or later.

The 33% tax

Every 3 bytes become 4 characters, so Base64 output is 4/3 of the input, rounded up to a multiple of 4:

InputBase64Overhead
3 bytes4 chars+33%
1 byte4 chars (TQ==)+300%
1 MB image≈1.33 MB+33%
1 MB image in an email≈1.37 MBemail adds a line break every 76 characters

Compression (gzip) wins some of it back on the wire, because each character only uses 6 of its 8 bits. But memory and parsing time pay the full price, and a Base64 string inside JSON must be decoded again after the JSON is parsed.

Practitioner§10

JSON vs binary formats

In one sentenceJSON is text that describes data. Binary formats store the same data as bytes laid out for machines. You trade human readability for size, speed and stricter types.

When a program sends an object over the network or saves it to disk, it first has to turn it into a flat sequence of bytes. That's called Serialization. JSON is the default choice for web APIs, and it's a good one. But it's worth knowing what it costs.

What JSON costs

main.go
package main

import (
    "encoding/json"
    "fmt"

    "github.com/vmihailenco/msgpack/v5" // go get github.com/vmihailenco/msgpack/v5
)

type User struct {
    ID     int      `json:"id" msgpack:"id"`
    Name   string   `json:"name" msgpack:"name"`
    Age    int      `json:"age" msgpack:"age"`
    Active bool     `json:"active" msgpack:"active"`
    Tags   []string `json:"tags" msgpack:"tags"`
    Avatar []byte   `json:"avatar" msgpack:"avatar"` // raw bytes
}

func main() {
    u := User{ID: 42, Name: "Zoë", Age: 31, Active: true,
        Tags: []string{"admin", "beta"}, Avatar: []byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x01}}

    j, _ := json.Marshal(u)
    fmt.Println(len(j), string(j))

    m, _ := msgpack.Marshal(u)
    fmt.Printf("%d % x\n", len(m), m)
}
// Output:
// 90 {"id":42,"name":"Zoë","age":31,"active":true,"tags":["admin","beta"],"avatar":"3q2+7wAB"}
// 60 86 a2 69 64 2a a4 6e 61 6d 65 a4 5a 6f c3 ab a3 61 67 65 1f a6 61 63 74 69 76 65 c3 a4 74 61 67 73 92 a5 61 64 6d 69 6e a4 62 65 74 61 a6 61 76 61 74 61 72 c4 06 de ad be ef 00 01

Reading the JSON line, you can count where the 90 bytes go:

  • Every key, every time. "id", "name", "active"… are spelled out in every record. In a list of 10,000 users, the key names are sent 10,000 times.
  • Numbers as text. 42 is two characters. A 64-bit ID like 9007199254740993 is 16 characters, and the parser has to turn the digits back into a number.
  • No bytes type. Bytes must become Base64 (§09), 33% bigger, and both sides must agree that this particular string is "really" bytes.
  • Escaping. Quotes, backslashes and control characters inside strings must be escaped (\", \\, \n), so a parser has to scan every character.

The big-integer trap

JSON itself allows numbers of any size, but JavaScript's JSON.parse turns every number into a regular JS number, which is a Floating-point number value. Integers above 2⁵³ − 1 aren't all stored exactly; the ones that can't be are silently rounded:

precision.js
JSON.parse('{"id": 9007199254740993}').id; // 9007199254740992  ← off by one, no error

Go is only safe if you ask for it. A typed int64 field decodes exactly, but decoding into any or map[string]any gives a float64, which rounds exactly like JavaScript:

main.go
data := `{"id": 9007199254740993}`

var typed struct{ ID int64 `json:"id"` }
json.Unmarshal([]byte(data), &typed)
fmt.Println(typed.ID) // typed field: exact

var loose map[string]any
json.Unmarshal([]byte(data), &loose)
fmt.Println(int64(loose["id"].(float64))) // any → float64: rounded

dec := json.NewDecoder(strings.NewReader(data))
dec.UseNumber() // keep numbers as text (json.Number)
var safe map[string]any
dec.Decode(&safe)
fmt.Println(safe["id"]) // exact
// Output:
// 9007199254740993
// 9007199254740992
// 9007199254740993

loose["id"].(float64) is a type assertion: "I know this any holds a float64". Use a typed struct or UseNumber() for IDs, and remember that the JS client can still round them. So a Go server and a JS client can disagree about the same ID. That's why APIs like Twitter's send large IDs as strings ("id_str"). Why floats work this way is covered in Numbers crossing boundaries; for now, remember the safe integer limit: 9,007,199,254,740,991.

A Go int64 crosses into JavaScript
Go int64: 9007199254740993→ JSON text →{"id": 9007199254740993}→ JSON.parse →9007199254740992
…990
…991
…9922⁵³
993 ↘ rounds to 992…993not a double
…994
…995not a double
…996
Doubles have 53 bits for the digits. Up to 2⁵³ every integer fits; above it only every second integer exists, then every fourth, and so on. JSON.parse picks the nearest one (on a tie, the even one) without any error.

Binary formats

Binary formats write values as compact bytes instead of text. Two families are worth knowing:

1

MessagePack (and CBOR)

MessagePack is "binary JSON": the same maps, arrays, strings and numbers, plus a real bytes type. Each value starts with a type byte (a4 = "string, 4 bytes"); small numbers 0–127 are packed into that byte itself (2a is both marker and 42, a fixint). Keys are still included, so no schema is needed.
2

Protobuf (and Avro, Thrift)

Protocol Buffers needs a Schema (a .proto file) on both sides. Keys are replaced by small field numbers, and integers use variable-length encoding. Much smaller, and the schema doubles as documentation.
3

JSON (the baseline)

Text, self-describing, readable with curl and a pair of eyes, supported by every language and tool. The safe default for public APIs.

The comparison below encodes the same record for real, in your browser, with small encoders that produce byte-for-byte the same output as the @msgpack/msgpack and protobufjs libraries. Pick a format to see its bytes; key names and field tags are highlighted:

Same data, four encodings
Sizes are real byte counts. Click a bar to see the bytes.
JSON, compact · 70 bytes■ key names
{"id":42,"name":"Zoë","age":31,"active":true,"tags":["admin","beta"]}
What you're looking at

Every key is spelled out in quotes for every record, numbers are written as digits.

Two things to notice. Protobuf is smallest because it sends 08 2a ("field 1, value 42") instead of "id":42. And the more bytes a record holds (switch on the avatar), the worse JSON does, because of Base64. For how Protobuf lays out field numbers and varints, see gRPC: On the wire.

Schema evolution: changing the shape later

Your data's shape will change: fields get added, renamed and retired. Each format handles that differently:

JSON / MessagePackProtobuf
Add a fieldfine if old readers ignore keys they don't know (most do)old readers skip unknown field numbers
Rename a fieldbreaks every reader using the old keyfree: only the number is on the wire
Remove a fieldreaders must cope with it missingreserve the number so it's never reused
Change a typenothing stops you; readers break at runtimesome changes are compatible, the rest are caught by tooling like buf breaking (a linter that compares two versions of a .proto file)

The rules for Protobuf are in gRPC: Changing the contract safely.

When to pick what

SituationPickWhy
Public HTTP API, browser clients, config filesJSONreadable, debuggable, universal
Internal service-to-service calls with a shared contractProtobuf (often via gRPC)small, fast, typed, safe evolution
JSON-shaped data, but bytes and size matter (caches, queues, game state)MessagePack / CBORno schema needed, real bytes type
Huge analytics datasetscolumnar formats (Parquet)a different problem: reading one column of a billion rows
Staff-level§11

Staff-level: BOMs, invalid bytes and truncation

In one sentenceReal-world input is full of bytes that aren't valid UTF-8. Decide on purpose whether each boundary rejects them or replaces them, and never cut text in the middle of a character.

Invalid UTF-8 and the replacement character

Not every byte sequence is valid UTF-8. A continuation byte with no lead byte, a lead byte followed by too few continuation bytes, or the Latin-1 byte 0xE9 sitting alone are all errors. Decoders have two choices:

  • Replace: put the Replacement character � (U+FFFD) where the bad bytes were, and carry on. This is the default almost everywhere.
  • Reject: stop with an error.
main.go
package main

import (
    "fmt"
    "strings"
    "unicode/utf8"
)

func main() {
    b := []byte{'c', 'a', 'f', 0xe9} // "café" saved as Latin-1, read as UTF-8
    s := string(b)                   // no error: a Go string can hold any bytes

    fmt.Println(utf8.ValidString(s))              // check before trusting
    fmt.Printf("%q\n", s)                         // the bad byte, visible
    fmt.Println(strings.ToValidUTF8(s, "\uFFFD")) // replace it with �
    for _, r := range s {
        fmt.Printf("%U ", r) // range yields U+FFFD for the bad byte
    }
    fmt.Println()
}
// Output:
// false
// "caf\xe9"
// caf�
// U+0063 U+0061 U+0066 U+FFFD

Notice that Go doesn't check anything when you convert bytes to a string. A Go string can hold any bytes; only range, the utf8 package and some encoders look at whether they're valid. encoding/json, for example, silently replaces invalid bytes with U+FFFD when it marshals a string.

JavaScript's version of the problem: lone surrogates

JS strings can contain a half surrogate pair (§06), for example after "😀".slice(0, 1). That isn't valid Unicode text. TextEncoder turns each lone half into U+FFFD (bytes ef bf bd), encodeURIComponent throws URIError, and JSON.stringify writes it as the escape "\ud83d" (since ES2019) so the output stays valid UTF-8. s.isWellFormed() and s.toWellFormed() (in Node 20+ and current browsers) check for and repair them.

The byte order mark

The Byte order mark is the code point U+FEFF at the very start of a file. In UTF-16 it tells the reader the byte order (FE FF = big-endian, FF FE = little-endian). UTF-8 has no byte order, but some Windows tools (older Notepad, Excel's "CSV UTF-8" export, PowerShell 5's -Encoding UTF8) still write the three bytes EF BB BF at the start as a "this is UTF-8" marker.

Parsers that don't expect it see a strange first character:

$ printf '\xef\xbb\xbf{"a":1}' | od -An -c
 357 273 277   {   "   a   "   :   1   }
  • Go: json.Unmarshal fails with invalid character 'ï' looking for beginning of value. The ï is 0xEF shown as Latin-1, mojibake inside an error message.
  • JS: JSON.parse("\uFEFF{}") throws SyntaxError: Unexpected token. But new TextDecoder().decode(bytes) strips a leading BOM by default, so reading files that way avoids it.
  • Shell scripts: a BOM before #!/bin/sh stops the kernel recognizing the shebang line, so the script won't run.
  • CSV: the first column header becomes "\uFEFFid", so row["id"] is undefined and nobody can see why.

Truncating to a byte limit

Database columns, message queues, push notifications and HTTP headers often limit bytes. Cutting at byte N can land in the middle of a multi-byte character and produce invalid UTF-8, which the next system may reject, replace with �, or choke on.

truncate.go
// TruncateBytes returns the longest prefix of s that is at most max bytes
// and doesn't split a UTF-8 character.
func TruncateBytes(s string, max int) string {
    if len(s) <= max {
        return s
    }
    // Step back while the byte at the cut is a continuation byte (10xxxxxx).
    for max > 0 && !utf8.RuneStart(s[max]) {
        max--
    }
    return s[:max]
}

// TruncateBytes("héllo", 2) → "h"   (cutting at 2 would split é)

These keep code points whole. To keep graphemes whole too (not splitting 👨‍👩‍👧 into its parts), step through graphemes with Intl.Segmenter or uniseg and stop before the one that would cross the limit.

Staff-level§12

Staff-level: security, HTTP charsets and databases

In one sentenceText handling is a security boundary. Validate, normalize, then compare, in that order, and make every layer (HTTP, HTML, database) declare UTF-8 instead of guessing.

Overlong encodings: the bug that made UTF-8 strict

§05's table says / (U+002F) is one byte, 2F. But the 2-byte pattern 110xxxxx 10xxxxxx could also hold the value 0x2F with leading zeros: C0 AF. That's an Overlong encoding.

Around 2000, Microsoft's IIS web server checked URLs for ../ (to block Path traversal) before decoding UTF-8, and a lenient decoder later turned %c0%af into /. Requests like /scripts/..%c0%af../winnt/system32/cmd.exe walked out of the web root (the folder the server is supposed to serve files from) and ran commands. Since then, the UTF-8 standard (RFC 3629) requires decoders to reject overlong forms, and Go and every browser do:

r, size := utf8.DecodeRune([]byte{0xc0, 0xaf})
fmt.Printf("%U %d\n", r, size) // U+FFFD 1  ← an error, never "/"

The general lesson outlives this bug: decode and normalize first, then validate. Checking the raw bytes and then transforming them lets the transformation create what the check was looking for.

Look-alike characters

Unicode has many homoglyphs: Latin a (U+0061) and Cyrillic а (U+0430) are drawn identically in most fonts. So аpple.com with a Cyrillic first letter is a different domain that looks like the real one.

Domain names with non-ASCII letters travel through DNS as Punycode. Browsers display that ASCII form when a name mixes scripts suspiciously, which exposes the fake:

new URL("https://\u0430pple.com/").hostname; // "xn--pple-43d.com"  ← not apple.com
new URL("https://münchen.de/").hostname;      // "xn--mnchen-3ya.de" (a real German city)

The same trick works on usernames, package names, and display names in chat apps. Related problems:

  • Invisible characters: the zero-width space (U+200B) and joiner make admin and ad + U+200B + min different strings that look identical on screen.
  • Bidirectional controls: U+202E (right-to-left override) makes text display in reverse order. In 2021 "Trojan Source" (CVE-2021-42574; a CVE is the public ID number given to a known vulnerability) showed source code that looked harmless in review but compiled differently. GitHub and most editors now warn about these characters.

Usernames: a recipe

If two accounts can have names that look the same, someone will impersonate someone. A practical pipeline when a user picks a name:

  1. Reject invalid UTF-8 (§11) and control characters.
  2. Normalize with Compatibility normalization, so john (full-width) and john become the same (§08).
  3. Case-fold (§08) to build a comparison key; keep the user's original spelling for display. Do this in one server-side function (NFKC → fold → NFKC, as in §08's Key): JavaScript has no exact fold.
  4. Restrict the allowed characters: for example, letters and digits from a single script plus _ and -. Mixing Latin and Cyrillic in one name is almost never legitimate.
  5. Check uniqueness on the key, with a unique database index on that column.
  6. For high-value names, also compare a "skeleton" from Unicode's confusables list (UTS #39), which maps 0/O and а/a to the same thing.

The IETF's PRECIS framework (RFC 8264/8265) and libraries implementing it (golang.org/x/text/secure/precis in Go) package steps 1–3.

HTTP: say the charset out loud

The charset is the name of the table the bytes were written with.

Bytes on the wire carry no encoding, so HTTP says it in the Content-Type header (see what an HTTP message looks like). The value is a media type plus an optional charset:

Content-Type: text/html; charset=utf-8
Content-Type: application/json
  • For text/ types, always add charset=utf-8. Without it, a browser may guess, and old rules default some types to Windows-1252: exactly the mojibake from §03.
  • JSON is defined as UTF-8 (RFC 8259) and application/json has no charset parameter. Adding one is harmless, and some old clients want it.
  • In HTML, also put <meta charset="utf-8"> in the first 1024 bytes of the page, so a file opened from disk or served without the header still decodes right.
  • Go's net/http sniffs the body and sets text/html; charset=utf-8 or text/plain; charset=utf-8 if you set no Content-Type. Set it yourself; sniffing is a guess.

Databases: encodings and collations

The encoding. In MySQL, the type called utf8 (now utf8mb3) stores at most 3 bytes per character, so it can't hold 😀 or any 4-byte code point. Inserts fail with Incorrect string value: '\xF0\x9F\x98\x80' or, with strict SQL mode turned off (MySQL then warns instead of rejecting bad data), the text is cut off. Use utf8mb4 for the table and the connection (it's the default since MySQL 8.0). In PostgreSQL, the encoding is chosen once per database at CREATE DATABASE; make it UTF8.

Lengths. VARCHAR(50) counts characters (code points) in both MySQL and Postgres, but index size limits are in bytes. That's why old MySQL schemas are full of VARCHAR(191): 191 × 4 bytes fits the 767-byte index limit of older InnoDB (MySQL's storage engine).

The collation. A Collation decides how text is compared and sorted. Byte order puts every uppercase letter before every lowercase one and accented letters last, which isn't what people expect:

main.go
package main

import (
    "fmt"
    "slices"

    "golang.org/x/text/collate"
    "golang.org/x/text/language"
)

func main() {
    words := []string{"zebra", "Éclair", "apple", "éclair"}

    slices.Sort(words) // compares bytes
    fmt.Println(words)

    collate.New(language.French).SortStrings(words) // language-aware
    fmt.Println(words)
}
// Output:
// [apple zebra Éclair éclair]
// [apple éclair Éclair zebra]

Collations also decide equality. MySQL's default utf8mb4_0900_ai_ci is accent-insensitive (ai) and case-insensitive (ci), so a unique index on username treats José and jose as duplicates. That's often what you want, but it surprises people who expect byte equality. In Postgres, the default collation is case-sensitive; use a lower-cased or folded column, or a nondeterministic ICU collation (Postgres 12+), for case-insensitive uniqueness.

Practitioner§13

Check yourself

Predict the answer before you click. Each explanation names the section that covers it.

QUESTION 1 / 6
Bytes in Go
s := "héllo"
fmt.Println(len(s))
QUESTION 2 / 6
Length in JavaScript
console.log("👍🏽".length);
QUESTION 3 / 6
Diagnose the mojibake

A customer's name “José” shows up in your admin panel as José. What happened?

QUESTION 4 / 6
Base64 size

How many characters is the standard (padded) Base64 encoding of 10 bytes?

QUESTION 5 / 6
Equal or not?
const a = "caf\u00e9";
const b = "cafe\u0301";
console.log(a === b, a.normalize("NFC") === b.normalize("NFC"));
QUESTION 6 / 6
A big ID through JSON
const order = JSON.parse('{"id": 9007199254740993}');
console.log(order.id);
Answered 0 / 6
Foundations§14

Cheat sheet

🔢
Bytes need a tableA byte is a number 0–255. Text exists only when reader and writer agree on the encoding. Wrong table → mojibake (café).
🗺️
Two steps, not oneUnicode: character → code point (é = U+00E9). Encoding: code point → bytes (UTF-8 c3 a9).
📦
UTF-8 everywhere1–4 bytes per code point, ASCII unchanged, self-synchronizing, no byte order. Declare it explicitly at every boundary.
📏
Four lengthsBytes (storage limits), UTF-16 units (JS .length), code points ([...s], Go runes), graphemes (what users see: Intl.Segmenter, uniseg).
⚖️
Normalize, then compareNFC for storage; NFKC + case folding for usernames and keys. toLowerCase() on both sides isn't case-insensitive comparison.
🧱
Base64 = 4 chars per 3 bytesMakes bytes text-safe, ~33% bigger, = pads the last group. base64url uses -_. Not encryption.
🧾
JSON's costsKeys repeated, numbers as text, no bytes type (→ Base64), integers above 2⁵³ − 1 lose precision in JS.
🛡️
Validate, normalize, compareReject invalid UTF-8 at trust boundaries, strip BOMs on input, never cut text mid-character, beware look-alikes.
I want to…GoJavaScript
text → UTF-8 bytes[]byte(s)new TextEncoder().encode(s)
UTF-8 bytes → textstring(b) (check utf8.Valid(b))new TextDecoder("utf-8", { fatal: true }).decode(b)
count byteslen(s)new TextEncoder().encode(s).length
count code pointsutf8.RuneCountInString(s)[...s].length
count graphemesuniseg.GraphemeClusterCount(s)[...new Intl.Segmenter().segment(s)].length
loop over charactersfor i, r := range s (i = byte offset)for (const ch of s)
normalizenorm.NFC.String(s) (golang.org/x/text)s.normalize("NFC")
compare ignoring casecases.Fold() / strings.EqualFold for ASCIIa.localeCompare(b, lang, { sensitivity: "base" })
Base64base64.StdEncoding / RawURLEncodingBuffer.toString("base64" | "base64url")
debug the bytesfmt.Printf("% x %q", s, s)Buffer.from(s).toString("hex") / od -An -tx1 file
SymptomLikely causeSection
café, ’UTF-8 read as Windows-1252§03
�invalid UTF-8, or a lone surrogate§11
"😀".length === 2UTF-16 surrogate pair§06
two equal-looking strings aren't ===different normalization (NFC vs NFD)§08
first JSON/CSV field fails to parsea BOM at the start of the file§11
emoji insert fails in MySQLutf8 column instead of utf8mb4§12
large ID off by one in the browserJSON number above 2⁵³ − 1§10
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Developer FoundationsNumbers in Computers
Read →