3. Strings & Text Processing

How each language builds, copies, indexes, and compares immutable strings efficiently — concatenation cost, interning, and UTF-16 traps.

Strings are immutable in Python, Java, Go, and JavaScript, which shapes every mechanical decision in this section — from why naive concatenation in a loop is a performance trap in all four, to how each language actually represents a character internally (Go is UTF-8 bytes; Java/JS are UTF-16 units; Python is code points). This is about the mechanics of building, comparing, and indexing text correctly and efficiently, not about string-matching algorithms or patterns (covered elsewhere in the roadmap).

See roadmap: Advanced String Algorithms

Language Verdict: Pros, Cons & Recommendation

Python

5/5
  • str is a sequence of Unicode code points, not UTF-16 units — len() and iteration stay correct even for emoji/astral characters
  • No reference-vs-value split: == always compares by value, with no new String()-style trap
  • F-strings compile to efficient bytecode — the fastest and most readable formatting option
  • "".join(parts) is one obvious idiom for O(n) building, no separate builder class needed
  • No dedicated builder class — you must know to accumulate into a list and join, not += in a loop
  • .lower()/.upper() follow Unicode default case folding, not locale rules — casefold() needed for robust comparisons

Java

4/5
  • StringBuilder is a dedicated, well-understood, unsynchronized builder — the unambiguous right tool for loop concatenation
  • toLowerCase(Locale)/equalsIgnoreCase() give explicit, mature control over locale-sensitive comparison
  • .codePoints()/Character.toChars() provide a direct, built-in fix for surrogate-pair iteration bugs
  • char/.length() operate on UTF-16 code units, not code points — surrogate pairs silently split under naive iteration
  • The string pool plus new String("x") creates the classic == vs. .equals() trap unique to Java among the three
  • String.format() allocates a Formatter and parses the format string at runtime — slower than plain concatenation

Go

5/5
  • strings.Builder is the dedicated, unambiguous loop-concatenation tool — same role as Java's StringBuilder
  • == compares string bytes by value; no new String() / intern pool trap
  • for _, r := range s iterates runes (code points), so emoji/astral characters don't split the way Java/JS char indexing does
  • []byte(s) / string(bytes) conversions are explicit; []byte is the mutable buffer when you need in-place ASCII edits
  • len(s) is bytes, not runes — len("😀") == 4; use utf8.RuneCountInString or []rune(s) when the problem counts characters
  • s[i] is a byte, not a rune — indexing into the middle of a UTF-8 sequence yields a continuation byte, not a character
  • Converting []byte(s) always copies; slicing a string (s[i:j]) is in bytes and panics if you slice mid-rune only at decode time, not at the slice itself

JavaScript

3/5
  • Primitive strings always compare by value with === — no Java-style reference trap outside new String() wrappers
  • Template literals are readable and as fast as a single + chain for one-off interpolation
  • for...of/spread iterate by code point, a simple built-in fix for common surrogate-pair cases
  • No built-in builder class at all — array-push-then-.join() is the only accumulation idiom
  • Same UTF-16-code-unit representation as Java ("\u{1F600}".length === 2), so the surrogate-pair trap applies equally
  • Full grapheme-cluster correctness (e.g. skin-tone emoji) needs the less-common Intl.Segmenter, not covered by basic iteration fixes
Recommendation: Python's code-point str and Go's rune-range loop both avoid the Java/JS surrogate-pair bug class; Python remains the default for speed of writing. In Java/JS/Go, always accumulate loop concatenation via StringBuilder / array+.join() / strings.Builder (never +=), and remember Go's len is bytes.

Coding Mechanics, Side by Side

The O(n^2) cost of concatenating in a loop

Must-know

Strings are immutable in all four languages, so s += x inside a loop reallocates and copies the entire string on every iteration — O(n) work done n times, O(n^2) total.

# BAD: O(n^2) -- each += allocates a new string and copies everything so far s = "" for word in words: s += word # GOOD: O(n) -- join allocates the final buffer once s = "".join(words)

CPython has a narrow optimization (+= on a string with refcount 1 may resize in place in some cases), but it's an implementation detail you should never rely on for correctness or complexity analysis. "".join(iterable) is the idiomatic O(n) fix — it computes the total length once and allocates a single buffer.

StringBuilder / StringBuffer / join idioms

Must-know
parts = [] for line in lines: parts.append(process(line)) result = "\n".join(parts)

Python has no separate builder type — the accumulate-into-a-list-then-join pattern is the StringBuilder equivalent, and it's the standard idiom for any loop that builds up text.

String interning & pooling

Recommended
a = "hello" b = "hello" print(a is b) # True (usually) -- CPython interns identifier-like/short literals c = "".join(["hel", "lo"]) print(a is c) # False -- runtime-built string, not automatically interned print(a == c) # True -- always compare strings with ==, never is

CPython automatically interns strings that look like identifiers and small string literals as an implementation-detail optimization — never rely on is for string equality, since it's not guaranteed by the language spec and can vary between builds/versions. Always use ==.

Char vs. code point: UTF-16 surrogate pairs

Must-know

The single most valuable cross-language difference here: Java and JS strings are sequences of UTF-16 code UNITS, Python 3 strings are sequences of Unicode code POINTS, and Go strings are sequences of UTF-8 BYTES (len is bytes; range yields runes).

s = "a\U0001F600b" # "a", then grinning-face emoji, then "b" print(len(s)) # 3 -- one code point per visible character for ch in s: print(ch) # iterates cleanly: 'a', the emoji, 'b'

Python 3 str is a sequence of Unicode code points, not UTF-16 units, so len() and naive character-by-character iteration both do the intuitively correct thing even for astral characters like most emoji. This is a genuine structural advantage over Java/JS for text-processing correctness.

Substring/slicing complexity

Recommended
s = "hello world" sub = s[0:5] # "hello" -- always allocates a new string, O(k) ch = s[1] # O(1) index access

Slicing always copies into a new string object, O(k) for a slice of length k — Python strings have never shared backing storage between a string and its substrings. Index access is O(1) since strings are stored as a contiguous code-point/byte buffer internally.

String comparison & the == trap

Must-know
a = "hello" b = "hel" + "lo" print(a == b) # True -- == always compares by value for str

Python's == compares string value correctly and consistently, regardless of how the strings were constructed — there is no reference-vs-value split to worry about, unlike Java. (is compares identity and should never be used for string equality, per the interning block above.)

Case-insensitive comparison & locale gotchas

Optional

Naive lowercase/uppercase conversion can surprise you in all four languages — the canonical trap is the Turkish 'İ'/'i' pair, where the dotless/dotted 'I' doesn't map the way English speakers expect.

print("HELLO".lower() == "hello") # True -- fine for ASCII/most text print("İstanbul".lower()) # 'i̇stanbul' -- dotted lowercase i + combining dot, len 9 not 8!

str.lower()/str.upper() use Unicode's default case-folding rules, which are locale-independent in Python (there's no locale-aware .lower() variant used by default) — but Unicode's own rules can still surprise you, as the Turkish İ example shows producing an extra combining character. For robust case-insensitive comparisons, prefer str.casefold() over .lower().

Building formatted strings

Recommended
name, score = "Ada", 97 s = f"{name} scored {score}%" # f-string: compiled, fast s2 = "{} scored {}%".format(name, score) # older .format() idiom, slightly slower

F-strings are parsed into efficient bytecode at compile time and are both the most readable and fastest option — prefer them over .format() or manual + concatenation. They're for single-expression formatting, not loop accumulation; for building up text across many iterations, still use "".join(...).

Further Reading

  • Text Sequence Type — strPython official docs

    The authoritative reference for str's methods and its code-point-based indexing model.

  • Covers String immutability, the string pool, intern(), and the UTF-16 char-based indexing model directly from the source.

  • Explains why StringBuilder (unsynchronized) is preferred over the legacy StringBuffer (synchronized) for single-threaded string building.

  • Canonical reference for JS string methods, including the UTF-16 code-unit semantics behind .length and indexing.

  • The spec-level definition of JS strings as sequences of UTF-16 code units, which explains the surrogate-pair length gotcha precisely.

  • You Don't Know JS: Types & GrammarYou Don't Know JS (Kyle Simpson)

    Digs into JS's primitive string vs String object distinction and the coercion quirks that trip up equality checks.

  • The canonical explanation of Go strings as immutable UTF-8 byte sequences, `len` in bytes vs ranging by rune, and why `s[i]` is a byte — the source of this section's indexing gotcha.