All articles
iOS8 min read

The Swift String Quirks That Separate Juniors from Seniors (Complete Guide 2025)

Master String operations, indexing, and performance tricks that’ll save your app from crashes and make your code interview-ready

K
Karan Pal
Author

Data Structures & Algorithms in Swift

Image Generated by AI
Image Generated by AI

🔤 Why Swift Strings Are… Different

So here’s the thing about Swift strings that nobody really prepares you for — they’re fundamentally different from strings in pretty much every other language you’ve probably used.

Coming from Objective-C? Java? Python? You’re used to accessing strings with integer indices like str[3]. In Swift? Nope. That doesn't work. And the first time you try it, you'll get a compiler error that makes you wonder if you're losing your mind.

let greeting = "Hello"
// let char = greeting[2]  // ❌ This won't even compile

Why though? What’s the deal?

It all comes down to Unicode. And specifically, how Swift handles it properly while most other languages… don’t.

See, Swift strings are designed around grapheme clusters — the actual visible characters you see on screen. Not bytes. Not UTF-16 code units. Actual characters as humans perceive them.

Here’s where it gets wild:

let flag = "🇺🇸"  // This looks like ONE character
print(flag.count)   // 1

// But under the hood...
print(flag.unicodeScalars.count)  // 2 (it's actually two Unicode scalars!)

That flag emoji? It’s actually TWO Unicode scalars combined. But Swift correctly treats it as one grapheme cluster because that’s what you SEE.

Or this one, which honestly blew my mind when I first learned it:

let family = "👨‍👩‍👧‍👦"  // Family emoji
print(family.count)  // 1

// But...
print(family.unicodeScalars.count)  // 7 (!!)

SEVEN Unicode scalars combine to make that one family emoji. And Swift handles this correctly by default. Most other languages would tell you that’s 7 characters.

This is why Swift doesn’t let you use integer indexing. The compiler can’t know at compile time how many bytes/scalars each “character” takes up. It varies. A lot.

So what does this mean for you?

It means Swift strings are slower than strings in languages that just treat everything as bytes or fixed-width characters. But they’re also WAY more correct. You don’t get weird bugs where emojis break your string manipulation.

The trade-off? You gotta learn a different way of working with strings. Which is exactly what we’re covering here.

📺 I’m putting together a complete video on Swift String gotchas and performance tricks. Subscribe to Swift Pal at _https://youtube.com/@swift-pal_ to catch it when it drops!

📍 String Indexing Explained (The Part Everyone Struggles With)

Alright, let’s tackle the elephant in the room — String indexing in Swift feels… weird. I get it. Everyone thinks that at first.

You can’t do string[3]. So what CAN you do?

Swift uses String.Index instead of integers. And yeah, it's more verbose. But once you understand why, it actually makes sense.

Getting a character at a specific position:

let text = "Hello, World!"

// Start index (first character)
let first = text[text.startIndex]  // "H"

// You can offset from start
let fourth = text[text.index(text.startIndex, offsetBy: 3)]  // "l"

// Or from the end
let lastChar = text[text.index(before: text.endIndex)]  // "!"

// End index is actually PAST the last character (like array indices)
// So you can't do text[text.endIndex] - that'll crash

See what I mean about verbose? But here’s why it works this way — because “3 characters from the start” might be 3 bytes, or 6 bytes, or 12 bytes depending on what those characters are. Swift’s Index type handles that complexity for you.

Safe indexing (the smart way):

let message = "Swift"

// This is safe - returns nil if offset is too large
if let index = message.index(message.startIndex, 
                             offsetBy: 10, 
                             limitedBy: message.endIndex) {
    print(message[index])
} else {
    print("Index out of range")  // This runs
}

That limitedBy parameter? Game changer. Returns nil instead of crashing. Use it.

Iterating with indices:

let word = "Hello"

// Old school way
for index in word.indices {
    print(word[index])
}

// Or just iterate characters directly (usually better)
for char in word {
    print(char)
}

Honestly? If you’re just going through each character, skip the indices entirely. Just iterate the string directly.

Finding an index:

let sentence = "The quick brown fox"

if let range = sentence.range(of: "quick") {
    print(sentence[range])  // "quick"
    
    // Start of the match
    let startIndex = range.lowerBound
    
    // Get character after the match
    if let nextIndex = sentence.index(range.upperBound, 
                                     offsetBy: 1, 
                                     limitedBy: sentence.endIndex) {
        print(sentence[nextIndex])  // " " (space)
    }
}

I know this looks complicated compared to sentence[5] but it prevents SO many Unicode-related bugs. Trust the process.

🛠️ Essential String Operations

Let’s get into the practical stuff. What operations do you actually need day-to-day?

Creating Strings

// Basic initialization
let simple = "Just a string"
let empty = ""
let alsoEmpty = String()

// From other types
let number = String(42)
let double = String(3.14159)
let bool = String(true)

// Multiline (super handy for JSON or SQL)
let multiline = """
    This is line one
    This is line two
    Indentation is relative to the closing quotes
    """

// Repeating
let stars = String(repeating: "*", count: 10)  // "**********"

// From an array of characters
let chars: [Character] = ["H", "i"]
let fromChars = String(chars)  // "Hi"

That multiline syntax is criminally underused. Makes dealing with JSON strings or SQL queries so much cleaner.

Checking Properties

let text = "Hello, World!"

// Is it empty?
if text.isEmpty {
    print("Nothing here")
}

// Length (careful - this can be O(n) for some strings!)
let length = text.count

// Has a prefix/suffix?
if text.hasPrefix("Hello") {
    print("Starts with Hello")
}

if text.hasSuffix("!") {
    print("Ends with !")
}

// Contains a substring?
if text.contains("World") {
    print("Found it")
}

Pro tip: count on strings can be O(n) because Swift has to iterate through all those grapheme clusters. If you're just checking if it's empty, use isEmpty instead—that's O(1).

Case Conversion

let mixed = "Hello World"

let upper = mixed.uppercased()  // "HELLO WORLD"
let lower = mixed.lowercased()  // "hello world"

// Capitalized (first letter of each word)
let capitalized = mixed.capitalized  // "Hello World"

// Turkish has different casing rules!
let turkish = "istanbul"
let turkishUpper = turkish.uppercased(with: Locale(identifier: "tr"))
// Different result than english uppercasing

Yeah, locales matter for case conversion. Turkish has a lowercase ‘i’ that doesn’t uppercase to ‘I’. Details like this matter in production apps with international users.

Concatenation & Interpolation

let first = "Hello"
let second = "World"

// Different ways to combine
let combined1 = first + " " + second
let combined2 = "\(first) \(second)"
let combined3 = [first, second].joined(separator: " ")

var mutable = "Hello"
mutable.append(" ")
mutable.append("World")  // "Hello World"

// String interpolation (way more readable usually)
let name = "Alice"
let age = 30
let message = "My name is \(name) and I'm \(age) years old"

// You can even run expressions in interpolation
let calculation = "2 + 2 = \(2 + 2)"

String interpolation is almost always more readable than concatenation. Use it.

Splitting & Joining

let csv = "apple,banana,orange,grape"

// Split into array
let fruits = csv.split(separator: ",")  // ["apple", "banana", "orange", "grape"]
// Note: split returns [Substring], not [String]

// Join array into string
let backTogether = fruits.joined(separator: ", ")  // "apple, banana, orange, grape"

// Split by whitespace
let sentence = "The quick brown fox"
let words = sentence.split(separator: " ")

// Split with options
let spacedText = "  Hello   World  "
let trimmed = spacedText.split(whereSeparator: { $0.isWhitespace })
// ["Hello", "World"] - automatically omits empty subsequences

That split method returns Substring not String. We'll get to why that matters in the next section.

Trimming & Padding

let messy = "  Hello World  \n"

// Trim whitespace and newlines
let cleaned = messy.trimmingCharacters(in: .whitespacesAndNewlines)
// "Hello World"

// Check if string is just whitespace
let onlySpaces = "   "
if onlySpaces.trimmingCharacters(in: .whitespaces).isEmpty {
    print("Nothing but spaces")
}

// Padding (you gotta build this yourself in Swift)
func padLeft(_ string: String, toLength length: Int, with char: Character = " ") -> String {
    if string.count >= length {
        return string
    }
    return String(repeating: char, count: length - string.count) + string
}

let padded = padLeft("42", toLength: 5, with: "0")  // "00042"

No built-in padding function. Annoying but whatever, it’s easy enough to write.

Replacing

let text = "Hello World World"

// Replace all occurrences
let replaced = text.replacingOccurrences(of: "World", with: "Swift")
// "Hello Swift Swift"

// Case insensitive replace
let caseInsensitive = text.replacingOccurrences(
    of: "world",
    with: "Swift",
    options: .caseInsensitive
)
// "Hello Swift Swift"

// Replace with regex (Swift 5.7+)
let withNumbers = "Call me at 555-1234"
let noNumbers = withNumbers.replacing(/\d/, with: "X")
// "Call me at XXX-XXXX"

The regex support in modern Swift is actually really nice. More on that later.

Searching

let text = "The quick brown fox jumps over the lazy dog"

// Does it contain something?
if text.contains("fox") {
    print("Found fox")
}

// Find range of substring
if let range = text.range(of: "brown") {
    print("Found at: \(range)")
    print("Substring: \(text[range])")
}

// First index of character
if let index = text.firstIndex(of: "q") {
    print(text[index])  // "q"
}

// Last index
if let lastIndex = text.lastIndex(of: "o") {
    print(text[lastIndex])  // "o" from "dog"
}

// Case insensitive search
if let range = text.range(of: "QUICK", options: .caseInsensitive) {
    print("Found it: \(text[range])")
}

✂️ Substrings & Performance (This Matters More Than You Think)

Okay so this is one of those things that seems like a minor detail but can absolutely wreck your app’s performance if you don’t understand it.

When you use operations like split() or subscript a range, Swift doesn't return String. It returns Substring.

let original = "Hello, World!"
let substring = original[original.startIndex..<original.index(original.startIndex, offsetBy: 5)]

print(type(of: substring))  // Substring

Why does Substring exist?

Performance. Memory efficiency.

When you create a Substring, Swift doesn’t copy the characters. Instead, it keeps a reference to the original String’s memory and just tracks which portion you care about.

let huge = String(repeating: "A", count: 1_000_000)  // 1 million characters

// This is basically free - no copying!
let tiny = huge.prefix(10)  // Substring of first 10 chars

// But 'tiny' keeps the ENTIRE 'huge' string in memory!

See the problem? Even though tiny only represents 10 characters, it's holding onto the memory for all 1 million characters of huge.

This is why you should convert to String when you’re done slicing:

let huge = String(repeating: "A", count: 1_000_000)
let tiny = String(huge.prefix(10))  // Now it's a String, copies just 10 chars

// 'huge' can now be freed if nothing else references it

When to convert Substring to String:

When to keep it as Substring:

Here’s a real-world example:

// BAD - stores Substrings, keeps entire original in memory
func parseCSVBad(_ csv: String) -> [Substring] {
    return csv.split(separator: ",")
}

let data = "apple,banana,orange,grape"
let items = parseCSVBad(data)  // All Substrings, 'data' can't be freed

// GOOD - converts to Strings
func parseCSVGood(_ csv: String) -> [String] {
    return csv.split(separator: ",").map(String.init)
}

let items2 = parseCSVGood(data)  // Proper Strings, 'data' can be freed

In interviews, if you mention this Substring optimization without being asked? Instant points.

🎯 Common Interview Patterns

These string problems come up constantly. Know these cold and you’re ahead of 70% of candidates.

Pattern 1: Reverse a String

// Easy way
let text = "Hello"
let reversed = String(text.reversed())  // "olleH"

// In-place (for character arrays)
var chars = Array("Hello")
var left = 0
var right = chars.count - 1

while left < right {
    chars.swapAt(left, right)
    left += 1
    right -= 1
}

let result = String(chars)  // "olleH"

Pattern 2: Check if Palindrome

func isPalindrome(_ text: String) -> Bool {
    let cleaned = text.lowercased().filter { $0.isLetter }
    return cleaned == String(cleaned.reversed())
}

// More efficient (doesn't create reversed string)
func isPalindromeFast(_ text: String) -> Bool {
    let cleaned = text.lowercased().filter { $0.isLetter }
    let chars = Array(cleaned)
    var left = 0
    var right = chars.count - 1
    
    while left < right {
        if chars[left] != chars[right] {
            return false
        }
        left += 1
        right -= 1
    }
    
    return true
}

print(isPalindrome("A man, a plan, a canal: Panama"))  // true

Pattern 3: First Unique Character

func firstUniqueChar(_ text: String) -> Character? {
    var counts: [Character: Int] = [:]
    
    // Count frequencies
    for char in text {
        counts[char, default: 0] += 1
    }
    
    // Find first with count of 1
    for char in text {
        if counts[char] == 1 {
            return char
        }
    }
    
    return nil
}

print(firstUniqueChar("leetcode") ?? "none")  // "l"
print(firstUniqueChar("loveleetcode") ?? "none")  // "v"

Pattern 4: Anagram Check

func areAnagrams(_ s1: String, _ s2: String) -> Bool {
    // Quick check - different lengths can't be anagrams
    guard s1.count == s2.count else { return false }
    
    // Sort and compare
    return s1.sorted() == s2.sorted()
}

// More efficient - O(n) vs O(n log n)
func areAnagramsFast(_ s1: String, _ s2: String) -> Bool {
    guard s1.count == s2.count else { return false }
    
    var counts: [Character: Int] = [:]
    
    for char in s1 {
        counts[char, default: 0] += 1
    }
    
    for char in s2 {
        counts[char, default: 0] -= 1
        if counts[char]! < 0 {
            return false
        }
    }
    
    return true
}

print(areAnagrams("listen", "silent"))  // true

Pattern 5: Longest Substring Without Repeating Characters

func lengthOfLongestSubstring(_ s: String) -> Int {
    var charSet = Set<Character>()
    var maxLength = 0
    var left = 0
    let chars = Array(s)
    
    for right in 0..<chars.count {
        while charSet.contains(chars[right]) {
            charSet.remove(chars[left])
            left += 1
        }
        
        charSet.insert(chars[right])
        maxLength = max(maxLength, right - left + 1)
    }
    
    return maxLength
}

print(lengthOfLongestSubstring("abcabcbb"))  // 3 ("abc")
print(lengthOfLongestSubstring("bbbbb"))     // 1 ("b")
print(lengthOfLongestSubstring("pwwkew"))    // 3 ("wke")

This is the sliding window pattern applied to strings. Super common in interviews.

Pattern 6: Valid Parentheses

func isValidParentheses(_ s: String) -> Bool {
    var stack: [Character] = []
    let pairs: [Character: Character] = [")": "(", "}": "{", "]": "["]
    
    for char in s {
        if pairs.values.contains(char) {
            // Opening bracket
            stack.append(char)
        } else if let opening = pairs[char] {
            // Closing bracket
            if stack.isEmpty || stack.last != opening {
                return false
            }
            stack.removeLast()
        }
    }
    
    return stack.isEmpty
}

print(isValidParentheses("()[]{}"))    // true
print(isValidParentheses("([)]"))      // false
print(isValidParentheses("{[]}"))      // true

💡 Advanced String Techniques

Regular Expressions (Swift 5.7+)

Swift’s new regex syntax is actually pretty nice:

let text = "My phone is 555-1234 and my zip is 12345"

// Old way - NSRegularExpression (still works)
let pattern = "\\d+"
if let regex = try? NSRegularExpression(pattern: pattern) {
    let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
    // ... process matches
}

// New way - Regex literals (Swift 5.7+)
let phonePattern = /\d{3}-\d{4}/
if let match = text.firstMatch(of: phonePattern) {
    print(match.0)  // "555-1234"
}

// Extract all numbers
let numbers = text.matches(of: /\d+/)
for match in numbers {
    print(match.0)  // "555", "1234", "12345"
}

// Replace using regex
let censored = text.replacing(/\d/, with: "X")
print(censored)  // "My phone is XXX-XXXX and my zip is XXXXX"

Character Sets

let text = "Hello123World"

// Check if string only contains certain characters
let alphanumeric = CharacterSet.alphanumerics
let isAlphanumeric = text.unicodeScalars.allSatisfy { alphanumeric.contains($0) }
print(isAlphanumeric)  // true

// Filter to only letters
let onlyLetters = text.filter { $0.isLetter }
print(onlyLetters)  // "HelloWorld"

// Filter to only numbers
let onlyNumbers = text.filter { $0.isNumber }
print(onlyNumbers)  // "123"

// Custom character set
let vowels = CharacterSet(charactersIn: "aeiouAEIOU")
let noVowels = String(text.unicodeScalars.filter { !vowels.contains($0) })
print(noVowels)  // "Hll123Wrld"

String Formatting

// Number formatting
let price = 1234.56
let formatted = String(format: "%.2f", price)  // "1234.56"
let padded = String(format: "%08.2f", price)   // "01234.56"

// Percentage
let percent = 0.1234
let percentStr = String(format: "%.1f%%", percent * 100)  // "12.3%"

// Using NumberFormatter (locale-aware)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale(identifier: "en_US")
let currencyStr = formatter.string(from: NSNumber(value: price))
// "$1,234.56"

Localization

// Localized strings
let welcome = NSLocalizedString("welcome_message", comment: "Welcome message")

// With interpolation
let greeting = String(format: NSLocalizedString("greeting", comment: ""), "Alice")

// String comparison with locale
let str1 = "café"
let str2 = "cafe"

// This might be true depending on locale!
let areEqual = str1.compare(str2, options: .diacriticInsensitive) == .orderedSame

⚠️ Unicode & Edge Cases (The Stuff That Breaks Your App)

This is where things get spicy. Unicode edge cases that’ll catch you if you’re not careful.

Emoji Gotchas

let thumbsUp = "👍"
print(thumbsUp.count)  // 1

// But with skin tone modifier...
let thumbsUpDark = "👍🏿"
print(thumbsUpDark.count)  // Still 1! (It's a grapheme cluster)

print(thumbsUpDark.unicodeScalars.count)  // 2

// Family emojis are wild
let family = "👨‍👩‍👧‍👦"
print(family.count)  // 1
print(family.unicodeScalars.count)  // 7!!

// This can break string truncation
func truncate(_ text: String, to length: Int) -> String {
    if text.count <= length {
        return text
    }
    
    // Using prefix keeps grapheme clusters intact
    return String(text.prefix(length)) + "..."
}

print(truncate("Hello 👨‍👩‍👧‍👦 World", to: 7))  // Doesn't break the emoji

Combining Characters

// These look the same but aren't equal
let e1 = "é"  // Single precomposed character
let e2 = "é"  // 'e' + combining acute accent

print(e1 == e2)  // true! Swift normalizes for comparison

print(e1.count)  // 1
print(e2.count)  // 1

print(e1.unicodeScalars.count)  // 1
print(e2.unicodeScalars.count)  // 2

// For storage/database, you might want to normalize
let normalized1 = e1.precomposedStringWithCanonicalMapping
let normalized2 = e2.precomposedStringWithCanonicalMapping
print(normalized1 == normalized2)  // true

Zero-Width Characters

// Zero-width space (invisible!)
let invisible = "Hello\u{200B}World"
print(invisible)  // Looks like "HelloWorld"
print(invisible.count)  // 11 (includes the invisible character!)

// This can mess up string matching
let search = "HelloWorld"
print(invisible == search)  // false!

// Strip zero-width characters
let cleaned = invisible.filter { !$0.isWhitespace || $0 == " " }

Normalization Issues

// Different Unicode representations of "same" string
let nfc = "Amélie".precomposedStringWithCanonicalMapping
let nfd = "Amélie".decomposedStringWithCanonicalMapping

print(nfc == nfd)  // true (Swift handles this)

// But byte-wise they're different
print(nfc.utf8.count)  // Different from nfd.utf8.count

// For APIs expecting specific normalization
let forAPI = text.precomposedStringWithCanonicalMapping

Right-to-Left Text

// Arabic text
let arabic = "مرحبا"
print(arabic.count)  // 5

// Mixing LTR and RTL can cause display issues
let mixed = "Hello مرحبا World"
// Display order depends on context - be careful with string manipulation!

Character Counting Pitfalls

// What you think might be a simple character...
let flag = "🇺🇸"
let family = "👨‍👩‍👧‍👦"  
let accent = "é"

// These are all count = 1 as grapheme clusters
// But very different at other levels

// For byte limits (like tweet length), use UTF-16 count
func twitterLength(_ text: String) -> Int {
    return text.utf16.count
}

// For display width, grapheme cluster count is usually right
func displayLength(_ text: String) -> Int {
    return text.count
}

🎬 Wrapping This Up

Look, strings in Swift are weird. I’m not gonna sugarcoat it. Coming from other languages, the indexing feels clunky, the performance characteristics are different, and Unicode handling adds a whole layer of complexity.

But here’s the thing — once you get it, you GET it. And you start appreciating that Swift actually handles all this Unicode stuff correctly by default. You’re not dealing with random emoji bugs or internationalization nightmares like in other languages.

The key takeaways?

String manipulation shows up in basically every iOS interview. Not because it’s the most important thing you’ll do as a developer, but because it’s a great way to test if you understand the language’s quirks and can write efficient code.

Master this stuff and you’ll stand out. Most juniors stumble on string indexing or don’t know about Substring optimization. Most seniors know this cold.

Which one do you want to be?

📺 Want to see these patterns in action with real examples? I’m working on a complete Swift String deep-dive video. Subscribe to Swift Pal at https://youtube.com/@swift-pal and you’ll catch it when it launches!

Now go forth and manipulate some strings. Just… do it efficiently. 🚀

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading