Java Strings and Text
Purpose
Treat text as encoded, locale-sensitive, attacker-influenced data with a cost model — because
every one of those four properties has a failure mode that looks like a String working
fine. The two most expensive: text that is correct in the developer's locale and encoding and
wrong in production, and text concatenated into something that interprets it (SQL, a shell, a
log line, a path).
Workflow
Inspect compiler release/toolchains, runtime JDK, locale/provider and boundary encoding before
changing behavior. No single authoring baseline is declared; references use Java SE 25.
Text blocks and formatted need Java 15+, records Java 16+, and default charset behavior
changes in Java 18. Unicode segmentation depends on the runtime version. Use supported
alternatives without upgrades or preview; missing protocol/column constraints remain unverified.
- Ask whether it should be a
Stringat all. An id, a status, a currency code, a compound key or a phone number wants a type with validation; aStringthere means every consumer re-validates or none does. - Pin the encoding at every boundary. Use the charset required by the protocol or storage contract—often UTF-8—on byte/string conversions, readers, writers and HTTP bodies.
- Pin the locale wherever text is transformed for a machine.
toLowerCase(Locale.ROOT),String.format(Locale.ROOT, …)for protocol text; the user's locale only for what a human reads. - Choose the composition mechanism by shape: a single expression →
+; a loop →StringBuilder; a collection →String.join/Collectors.joining; multi-line literal → a text block; user-facing formatting →String.format(locale, …)orMessageFormatwith the user's locale.formattedhas no locale overload and uses the process default. - Reuse stable, repeated
Patterns, and check what happens when input is hostile—length bound, nesting, backtracking. Dynamic or one-shot expressions do not belong in global state. - Check every place text is embedded into another language and replace concatenation with the parameterised mechanism that language provides.
Rules
- Do not use
Stringwhere a type exists or can be made. Enums for closed sets (java-enums), a record or value object for ids and codes,java.timefor timestamps,BigDecimal/longfor amounts,URI/Pathfor locations. AStringparameter accepts every wrong value in the universe and documents none of them. - Never build a compound key by concatenation (
tenant + "#" + id). It breaks the moment a component contains the separator, it cannot be parsed back safely, and it makes every consumer a parser. Use a record as the key — it getsequals/hashCodefor free. length()counts UTF-16 code units, not characters. Characters outside the Basic Multilingual Plane — emoji, many CJK extensions, some scripts — take two units, sosubstring(0, 100)can split a surrogate pair and produce invalid text. UsecodePointCount/offsetByCodePointswhen the unit is a code point, andBreakIteratorwhen the unit is what a user perceives as a character (an emoji with a skin-tone modifier is several code points and one grapheme).- Pass the contract's charset.
String.getBytes(),new String(byte[]),FileReader,InputStreamReaderandPrintWriterwithout one use a default that has changed across versions. Since Java 18 it is UTF-8 by default, while-Dfile.encoding=COMPATselects the native encoding; other overrides have unspecified behaviour. Explicit UTF-8 is portable only when UTF-8 is actually the boundary contract—legacy files and protocols may require another explicit charset. - Pass a
Localeto every case conversion and format call whose result is consumed by a machine."TITLE".toLowerCase()is"tıtle"in a Turkish locale — the dotless ı — so a case-insensitive comparison of a header, a code or an enum name fails on a machine whose locale differs from the developer's. UseLocale.ROOTfor protocol-defined case mapping.equalsIgnoreCaseavoids allocation but is locale-independent simple Unicode comparison, not human-language collation or a universal identifier canonicalizer. The same applies toString.format("%.2f", …), which emits a comma decimal separator in many locales;String.formattedalso uses the default formatting locale. - Concatenation in a single expression is fine. Modern
javaccommonly usesStringConcatFactory; the language specification intentionally leaves the implementation to the compiler. Repeatedresult += fragmentin a loop can copy an ever-growing prefix and become quadratic. Use a locally ownedStringBuilderin loops,String.joinorCollectors.joiningfor collections. - Do not micro-optimise concatenation outside loops, and do not replace readable expressions
with
StringBuilderchains on a hunch. If string building appears in a profile, that is evidence; otherwise it is noise (performance-methodology). - Reuse a
private static final Patternwhen the same non-trivial expression is matched repeatedly.matchesand regex replacement convenience methods compile their expressions;splitis specified in terms of pattern compilation although implementations may optimize simple delimiters. Do not retain data-dependent patterns forever, and measure before building a pattern cache—unbounded cardinality merely changes an allocation cost into a leak. - A regex applied to untrusted input is an availability risk. Nested quantifiers over
alternation (
(a+)+,(\w+\s?)*) can backtrack exponentially, and Java's engine has no timeout: one request pins a CPU core until it finishes. Bound the input length, avoid nested quantifiers, consider possessive quantifiers or atomic groups only after checking accepted inputs and captures remain correct, and prefer a real parser for structured input. Where a regex must run on user input, run it with a bounded input size and treat a hang as a possible ReDoS, not a slow query. - Never build SQL, shell commands, HTML or LDAP filters by concatenating
user text. Use prepared statements with parameters,
ProcessBuilderwith an argument list, and a templating engine with contextual escaping. For paths, lexicalnormalize/startsWithchecks do not defeat symlinks or races: resolve against a trusted real base, constrain allowed names, and use filesystem-specific secure traversal where the threat model requires it. Structured logging preserves field boundaries, but the encoder/sink must still escape control characters to prevent log forging (structured-logging). - Use text blocks for multi-line literals — SQL, JSON, HTML — instead of escaped concatenation. They preserve readable indentation and remove the escaping mistakes; they do not make embedded user input safe, so parameters still go through the mechanism above. String templates were previewed and then withdrawn from the JDK; do not design around them.
- Do not use
String.intern()as an unmeasured deduplication strategy for unbounded external data. It adds shared-table lookup/coordination and couples retention/GC behaviour to the JVM implementation. First prove duplicate strings dominate the heap; then compare G1 string deduplication, bounded caches with real eviction, or representation changes (java-reference-types-and-leaks). - Define and version a Unicode canonicalization policy before comparing or storing identifiers
that people type. The same visible
text can be several code-point sequences (
écomposed or decomposed);Normalizer.normalize(s, NFC)is a common preservation-oriented policy, but protocols, search and security-sensitive identifiers may require case folding, NFKC, script restrictions or no normalization. Java and the database must enforce the same rule. - Byte length and character length are different limits. A
VARCHAR(50)may mean 50 bytes or 50 characters depending on the database and collation, so validation written in Java characters can pass while the insert fails. Validate against the real constraint.
References
Deliver the boundary contract (encoding, malformed-input policy, locale and length unit), the smallest justified change, and checks run on the target. Include malformed bytes, supplementary/combining text, locale differences and rejected inputs when relevant. Separate semantic correctness from measured regex/performance claims; report unavailable evidence.
- Encoding, locale and Unicode — read when text crosses a file, socket, database or process boundary, when it is truncated or compared case-insensitively, or when a bug appears only for some users' data or on some machines.
- Building text: concatenation, regex and injection — read when composing strings in a loop or a hot path, when writing or reviewing a regex over untrusted input, or when text is embedded into SQL, a command, a path, a template or a log.