Available for work
Ankit Ranjan
Back to Deep Dives

Strings in Swift — Unicode Correctness, Grapheme Clusters, and Why count is O(n)

Swift treats a String as a collection of human-perceived characters rather than bytes or code units. That choice buys us Unicode correctness for free, and it costs us O(1) indexing. Let's see why the trade was worth it.

June 4, 2026 7 topics 7 quiz questions
Share:
1

A String is a value type and a Collection of Character

Most languages treat a string as an array of bytes, or an array of code units, and let the programmer worry about what a "character" really means. Swift made a different, opinionated choice. A String is a value type — copying one gives you an independent copy, with copy-on-write keeping that cheap — and it is a Collection whose element type is Character.

So when we iterate a Swift string, we do not get bytes. We do not get code units. We get Character values, one per visible character, in order.

let greeting = "Café"
for c in greeting {
    print(c)   // C, a, f, é  — four Characters
}
print(type(of: greeting.first!))   // Character
This is the thread that runs through the whole episode. Swift decided that the natural unit of a string is the thing a human points at and calls "a character", and it built the entire API around that decision. Almost everything that surprises newcomers — why count is slow, why you cannot write s[2], why two differently encoded strings can still be equal — falls out of this one commitment.

The contrast with Dart is sharp. In the Dart series we saw that a Dart String is a sequence of UTF-16 code units, so iterating or measuring it exposes the encoding. Swift hides the encoding behind a model built around human perception. Both are defensible. They simply optimise for different things, and the rest of this episode is about that fork in the road.

2

Character = extended grapheme cluster

So what exactly is a Character? It is what the Unicode standard calls an extended grapheme cluster — the smallest sequence of Unicode scalar values that a reader perceives as one character. The crucial word is sequence. A single Character may be built from several scalars working together.

Take the letter "é". It can be a single scalar, U+00E9 (LATIN SMALL LETTER E WITH ACUTE). Or it can be two scalars: a plain "e" (U+0065) followed by a combining acute accent (U+0301) that floats on top of the previous letter. Both spellings render identically, and Swift treats each as exactly one Character.

It gets richer. A flag emoji such as the flag of Japan is two regional indicator scalars (a regional "J" plus a regional "P"). A family emoji is several full emoji glued together with invisible Zero-Width Joiners.

One Character, Five Scalars👨‍👩‍👧one Character (extended grapheme cluster)👨U+1F468manZWJU+200D👩U+1F469womanZWJU+200D👧U+1F467girl.count== 1Five scalar values fuse into a single human-perceived Character.

let family = "👨‍👩‍👧"
print(family.count)   // 1  — one Character

let flag = "🇯🇵"
print(flag.count)     // 1  — one Character
Swift even updates its notion of grapheme clusters as the Unicode standard evolves, which is why the same code can count newer emoji correctly after a runtime update. The unit is human perception, and human perception is exactly what Unicode keeps refining.

3

Why count is O(n) and there is no Int subscripting

Here is the price of that decision. Because grapheme clusters are variable width — one cluster might be a single byte, the next might be eight — Swift cannot know where the fifth character lives without walking past the first four. There is no formula that turns "character number 5" into a memory offset.

Why There Is No s[2] — Walking by String.Indexlet s = "caé🎯" — clusters have different widths in memoryc1 bytea1 byteé2–3 bytes🎯4 bytesstartIndexEach step asks the previous cluster how wide it is. You cannot jump.let third = s[2]does not compile — String is not Int-subscriptablelet third = s[s.index(s.startIndex, offsetBy: 2)]
So two things follow. First, count is O(n): to count the characters, Swift must walk the whole string and segment it into clusters. Second, String is deliberately not subscriptable by Int. You cannot write s[2]. Instead you index with a String.Index, which represents a real position reached by walking from a known one.

let s = "caé🎯"
// let third = s[2]            // compile error
let i = s.index(s.startIndex, offsetBy: 2)
print(s[i])                    // "é"
print(s.count)                 // 4  — computed in O(n)
This feels hostile at first, especially coming from languages where s[2] is free. But it is honest. An Int subscript on a Unicode string is a lie in any language that offers it cheaply, because the integer indexes code units, not characters. Swift refuses to hand us a fast answer to the wrong question. Dart, by contrast, gives us O(1) indexing precisely because it indexes UTF-16 code units — which is why "🎯".length is 2 in Dart, while "🎯".count is 1 in Swift. One number is fast; the other is correct.

4

The four views of a string

Swift does not pretend the encoding does not exist. It just refuses to make it the default. When we genuinely need bytes or code units — for a network protocol, a file format, a C API — we ask for the appropriate view. The same string offers four of them, each a Collection at a different granularity.

1. The Character collection — the string itself, one grapheme cluster per element.
2. unicodeScalars — the individual Unicode scalar values (code points, excluding surrogates).
3. utf16 — UTF-16 code units, the same granularity Dart's .length exposes.
4. utf8 — UTF-8 code units, which are simply the bytes.

For plain ASCII all four counts agree. For anything interesting they diverge, and the divergence is the whole point.

Four Views of let s = "🇯🇵"the same string, measured four wayss.countCharacters (grapheme clusters)1s.unicodeScalars.countUnicode scalar values2s.utf16.countUTF-16 code units4s.utf8.countUTF-8 code units (bytes)8The flag is one grapheme cluster, built from two regional-indicatorscalars (🇯 + 🇵). Each scalar is above U+FFFF, so each needs aUTF-16 surrogate pair (4 units total) and four UTF-8 bytes (8 bytes total).Pick the view that matches your question: people see Characters; protocols speak bytes.

let s = "🇯🇵"   // flag of Japan
print(s.count)                  // 1
print(s.unicodeScalars.count)   // 2
print(s.utf16.count)            // 4
print(s.utf8.count)             // 8
One string, four honest answers. The skill is knowing which question you are actually asking. "How many characters will the user see?" is count. "How many bytes must I send?" is utf8.count. They are not the same number, and Swift makes you say which one you mean.

5

Canonical equivalence — when different bytes are equal

Now for the feature that most languages get quietly wrong. Recall the two spellings of "é": the single scalar U+00E9, and the pair "e" + U+0301. They look identical. Are they equal?

In Swift, yes. The == operator on String compares by Unicode canonical equivalence, not by raw bytes. Two strings are equal if they mean the same thing to Unicode, even when their underlying utf8 bytes differ.

Canonical Equivalence — Two Spellings, One Meaningcomposed (NFC)é1 scalar: U+00E9utf8 bytes: C3 A9decomposed (NFD)2 scalars: U+0065 U+0301utf8 bytes: 65 CC 81==a == b → true (even though a.utf8 != b.utf8)Swift compares by Unicode canonical equivalence, not raw bytes. Most languages get this wrong.

let composed   = "\u{00E9}"          // é
let decomposed = "e\u{0301}"         // e + combining acute

print(composed == decomposed)        // true
print(composed.count)                // 1
print(decomposed.count)              // 1
print(Array(composed.utf8))          // [195, 169]
print(Array(decomposed.utf8))        // [101, 204, 129]
Same meaning, same character count, different bytes — and still equal. This is the correct behaviour, and it is rare. In most languages, two strings that look the same on screen can compare as unequal because one came from a Mac filesystem (which favours decomposed forms) and the other from a web form (which favours composed forms). Swift spares us that entire class of bug by default. The cost, of course, is that == on strings is not a byte comparison and cannot be O(1) in the general case — yet another correctness-over-speed trade, made on purpose.

6

UTF-8 backing and the String revolution

What lives underneath all this? Since Swift 5, the native storage for a String is UTF-8. This was a substantial piece of engineering known informally as the "String revolution", driven on the standard-library side by Michael Ilseman and the Swift team, and it shipped in 2019.

It was not always so. Earlier Swift could back a string with UTF-16, largely so that bridging to NSString on Apple platforms — which is UTF-16 internally — could be cheap. But the centre of gravity for Swift shifted. Server-side Swift, Linux, and the web all speak UTF-8. The web is overwhelmingly UTF-8. Most text in the wild, especially ASCII-heavy content like JSON, HTML, and source code, is far more compact in UTF-8 than in UTF-16. So UTF-8 won on memory and on interoperability, and Swift moved its native representation accordingly.

let s = "hello"
// Backed by UTF-8 in contiguous memory.
s.withContiguousStorageIfAvailable { _ in /* fast path */ }
Array(s.utf8)   // [104, 101, 108, 108, 111]
There is a second optimisation worth knowing: the small-string optimisation. A short string (on a 64-bit platform, up to fifteen UTF-8 bytes) is stored inline inside the String value itself, with no heap allocation at all. Names, keys, short identifiers, and the like never touch the heap. Only when a string grows past that inline budget does Swift allocate backing storage and switch on copy-on-write. For the countless tiny strings a real program juggles, this is a quiet but large win.

7

Practical takeaways and trivia

Let's pull it together into a working mental model.

Reach for the right view. For anything a person reads — truncating a label, counting characters, reversing text — work with Character values and count. For protocols, hashing, and storage, use utf8. Use utf16 mainly when interoperating with APIs that speak code units, such as ranges handed to you by Cocoa text systems. Reach for unicodeScalars when you care about code points specifically, for example classifying or normalising.

Bridging to NSString. On Apple platforms String bridges to NSString, but their world views differ: NSString's length and integer indices are UTF-16 code units, exactly like Dart's .length. When you take an NSRange from a UIKit or AppKit text component and want to apply it to a Swift String, convert through the utf16 view and String.Index so you do not slice through a character.

// Wrong mental model: "char at integer position".
// Right model: index by walking, measure by the right view.
let s = "Résumé"
print(s.count)               // 6 Characters
print(s.unicodeScalars.count)// 8 scalars
print(s.utf8.count)          // 10 bytes
The cross-language picture. Where Swift bakes grapheme-cluster correctness into the core type, other languages bolt it on. Dart needs the package:characters library to count grapheme clusters; without it, "😀".length is 2 because Dart counts UTF-16 code units. JavaScript has the same UTF-16 default and reaches for Intl.Segmenter. Python 3 counts Unicode scalars, so len("😀") is 1 there but len("🇯🇵") is 2 — closer to Swift's scalar view than to its character view. Swift is unusual in making the human-perceived character the default unit of the language's own string type.

One last contrast to close the loop with the Dart episode. In Dart, "🎯".length == 2 because the string is UTF-16 and the dart emoji is a surrogate pair. In Swift, "🎯".count == 1 because the string is a collection of grapheme clusters. Neither is wrong. Dart optimises for a fast, predictable code-unit model and asks you to opt in to correctness; Swift optimises for correctness and asks you to opt in to speed. Knowing which model you are standing in is the whole game.

Test your understanding

7 questions

Seven questions on Swift's String model: grapheme clusters, the O(n) count, the four views, canonical equivalence, and UTF-8 backing.

Search

Loading search...