I still see production Java code where “joining two strings” looks like a tiny, harmless detail—and then it becomes the reason a hot path allocates like crazy, a log line randomly says "null", or a background job crashes with a NullPointerException at 2 a.m. String concatenation is deceptively simple: you want two pieces of text to become one. But in Java, the way you join text changes semantics (null handling, type conversion), readability, and often performance.
When I review code, I treat string joining as a first-class decision, not a style preference. The String.concat() method and the + operator can produce the same visible output in the happy path, but they behave differently in edge cases and compile down differently.
You’re going to see exactly what each one does, where it can surprise you, and how I choose between them in modern Java (including what the compiler and runtime typically do under the hood). I’ll also show you what not to do in loops, and what I reach for instead when the output matters—like logging, JSON-ish payloads, CSV exports, SQL fragments, and user-facing messages.
Two Ways to Join Text—and Why They Behave Differently
At a glance, these feel equivalent:
public class Demo {
public static void main(String[] args) {
String a = "Hello";
String b = " world";
System.out.println(a.concat(b));
System.out.println(a + b);
}
}
In the simplest case, both print the same thing. But they are different constructs:
concat()is a method onString. That means it has a fixed signature, fixed rules, and it can only do what theStringAPI permits.+is a language operator. The Java Language Specification defines what it means when one operand is aString. The compiler is allowed to translate it into more complex bytecode.
Here’s the mental model I use:
concat()is like saying: “Take this string instance and append that other string instance.”+is like saying: “Build a string representation of these expressions and join them left-to-right.”
That second phrasing is why + feels flexible: it can accept primitives, objects, and even null, because the spec defines how non-strings are converted into strings.
Quick behavioral map
Before we go deep, this is the cheat sheet I keep in my head:
String.concat()
+ with a String involved —
String argument Yes
null on the right side Throws NullPointerException
"null" One argument per call
Not usually (method call)
Bad choice (allocates per step)
StringBuilder / StringJoiner
StringBuilder / StringJoiner Now let’s get precise.
What concat() Actually Does (and What It Refuses to Do)
String.concat(String str) appends the provided string to the end of the current string and returns a new String. It does not mutate the original string (strings are immutable in Java).
A runnable example:
public class ConcatBasics {
public static void main(String[] args) {
String prefix = "build=";
String number = "173";
String result = prefix.concat(number);
System.out.println("prefix: " + prefix);
System.out.println("result: " + result);
}
}
You’ll see that prefix stays "build=".
The argument must be a String (no automatic conversion)
This is one of the most important differences: concat() only accepts a String parameter.
public class ConcatTypeRules {
public static void main(String[] args) {
String label = "status=";
int code = 200;
// String ok = label.concat(code); // does not compile
String ok = label.concat(String.valueOf(code));
System.out.println(ok);
}
}
If you want the code to compile, you must convert the value yourself.
I actually like this property in codebases where accidental concatenation is a bug. For example, if you’re building a cache key and you forget a delimiter, + will happily concatenate and ship a key collision. With concat(), you still can make that mistake, but you’re more often forced to think about conversions.
null is a hard error
concat() throws NullPointerException if the argument is null.
public class ConcatNull {
public static void main(String[] args) {
String left = "user=";
String username = null;
// This prints "user=null" with +
System.out.println(left + username);
// This throws NullPointerException
System.out.println(left.concat(username));
}
}
In my experience, this is the single most common "why did prod crash" difference between the two.
I don’t consider this purely negative. If null is unacceptable in that path, concat() fails fast and makes it obvious. If null is normal (optional fields), then concat() is the wrong tool unless you normalize first.
One-at-a-time chaining is verbose
concat() takes one argument. So joining many parts means chaining:
public class ConcatChaining {
public static void main(String[] args) {
String url = "https://api.example.com"
.concat("/v1")
.concat("/users")
.concat("/")
.concat("42");
System.out.println(url);
}
}
This works, and sometimes it reads cleanly (it resembles fluent APIs). But it’s still multiple calls and still creates intermediate strings along the way.
What the + Operator Means for Strings
The + operator is overloaded by the language: if either operand is a String, Java performs string concatenation.
The important part is not that + can join two strings—it’s that it can join expressions.
public class PlusBasics {
public static void main(String[] args) {
String method = "GET";
String path = "/health";
int status = 200;
String line = method + " " + path + " -> " + status;
System.out.println(line);
}
}
That status is an int, and the expression still compiles. With +, Java calls into String.valueOf(...) for primitives and objects.
Multiple operands are natural
Joining several pieces is where + looks nice:
public class PlusManyPieces {
public static void main(String[] args) {
String service = "billing";
String region = "us-east";
long requestId = 88423319L;
String tag = "service=" + service + ", region=" + region + ", requestId=" + requestId;
System.out.println(tag);
}
}
If you tried to write that with concat(), you’d end up converting primitives manually and chaining calls.
Compile-time constant folding (a quiet superpower)
One detail I rely on in real code: constant concatenations using + can be folded by the compiler.
public class ConstantFolding {
private static final String HEADER = "X-Client-" + "Version";
public static void main(String[] args) {
System.out.println(HEADER);
}
}
In typical builds, HEADER ends up as a single constant string in the compiled output, not something built at runtime.
That’s not guaranteed in the same way for "X-Client-".concat("Version") because that’s a method call. The runtime might inline it, but it’s not the same “free at compile time” transformation.
null becomes the literal text "null"
This is often a footgun:
public class PlusNull {
public static void main(String[] args) {
String prefix = "tenant=";
String tenant = null;
System.out.println(prefix + tenant); // tenant=null
}
}
Sometimes that is exactly what you want (especially for debugging logs). Sometimes it hides a bug for months.
When I want safety, I prefer to make null handling explicit:
public class ExplicitNullHandling {
public static void main(String[] args) {
String prefix = "tenant=";
String tenant = null;
String safeTenant = (tenant == null) ? "" : tenant;
System.out.println(prefix + safeTenant);
}
}
Nulls, Primitives, and Type Conversion: The Gotchas You Actually Hit
This section is where most production surprises live. The code compiles, tests pass, and then a rare edge case shows up.
1) concat() fails on null; + prints null
I already showed the basic behavior. The real-world version is more subtle:
- With
+, you can accidentally ship user-visible text containing “null”. - With
concat(), you can accidentally crash a request path that should have degraded gracefully.
If this is user-facing output, I strongly recommend treating null as a data-quality issue and normalizing early (DTO mapping, validation) rather than patching it at concatenation time.
2) + converts primitives and objects using String.valueOf
For primitives:
public class Primitives {
public static void main(String[] args) {
int retries = 3;
boolean healthy = true;
String message = "retries=" + retries + ", healthy=" + healthy;
System.out.println(message);
}
}
For objects, Java calls toString() (through String.valueOf(Object)), and if the object is null, you get the literal text "null".
This is handy, but it can hide expensive toString() implementations. I’ve seen objects whose toString() serializes a whole JSON tree; concatenating them in debug logs can add real latency.
If you’re in a performance-sensitive area, keep logs parameterized (your logging framework can decide when to evaluate arguments) rather than concatenating eagerly.
3) Operator precedence can change the meaning
This one bites people when numbers are involved:
public class Precedence {
public static void main(String[] args) {
int a = 10;
int b = 20;
System.out.println("a + b = " + a + b); // a + b = 1020
System.out.println("a + b = " + (a + b)); // a + b = 30
}
}
The moment the left side becomes a string, the rest becomes string concatenation, left-to-right.
If you use concat(), you’re forced into more explicit conversions, which can prevent this exact mistake—but you can still build the wrong string if you convert too early.
4) Empty strings: a small but useful behavior
concat() commonly has a fast path when the argument is empty, returning the original string unchanged (implementation detail, but typical). With +, the compiler/runtime may also avoid work in trivial cases.
In practice, I don’t write code that relies on these micro-behaviors. If an empty value is meaningful, I handle it explicitly (especially when generating URLs, CSV, or file paths).
Performance and Allocation: What Happens at Runtime
If you only take one performance lesson from this post, take this:
- In single expressions with a few parts,
+is typically fine and very readable. - In loops or repeated appends,
+and chainedconcat()are both a trap.
Strings are immutable: every “append” creates a new string
Java strings are not C-style null-terminated character arrays. They’re objects with a length and internal storage. Because they’re immutable, concatenation always produces a new string.
A simple analogy I use: imagine each String is a printed book. You can’t “add a page” to a book without printing a new edition.
So this:
String s = "";
for (int i = 0; i < 10_000; i++) {
s = s + i;
}
creates many intermediate books you immediately throw away.
How + is compiled in modern Java
In older Java versions, a + b + c often compiled into something like:
- create
StringBuilder - append
a, appendb, appendc - call
toString()
In modern Java (Java 9+), + concatenation is often compiled to an invokedynamic call that uses StringConcatFactory. The runtime can choose a strategy that’s efficient for the shapes it sees.
What matters for you as an application developer:
+in a single statement is usually translated into something reasonably efficient.+across a loop boundary can’t be “magically fixed” by the compiler because each iteration depends on the previous string.
How concat() tends to behave
concat() is a straightforward API: it checks the argument and produces a new String. You still get intermediate allocations if you chain it repeatedly.
So performance-wise, the real comparison is often:
+for a few parts in one expression (good readability, typically good performance)concat()when you want strict string-only semantics and strict null failureStringBuilder(orStringJoiner) when you’re building over time
Realistic performance guidance (not benchmark theater)
Microbenchmarks can be misleading unless you use a harness like JMH and understand warm-up, dead-code elimination, and allocation rates.
What I see in typical services:
- A handful of
+concatenations per request rarely dominates latency. - A hot loop that appends thousands of times can add noticeable GC pressure and show up as elevated allocation rate.
- The best improvement is usually architectural: build fewer strings, or build them once.
If you’re generating output for an HTTP response, prefer composing data structures and letting your serializer write to a stream rather than building giant intermediate strings.
Loops, Builders, and Modern Alternatives I Reach For
When you repeatedly append, stop thinking “concat vs +” and start thinking “builder vs joiner vs formatter”.
1) StringBuilder for iterative construction
StringBuilder is the default tool for appending in a loop.
public class BuildCsv {
public static void main(String[] args) {
String[] columns = {"orderId", "customerId", "total"};
StringBuilder csv = new StringBuilder();
for (int i = 0; i < columns.length; i++) {
if (i > 0) {
csv.append(‘,‘);
}
csv.append(columns[i]);
}
System.out.println(csv.toString());
}
}
If you want a rule you can teach a team: if you’re appending inside a loop, use StringBuilder unless you can use a join API.
A small practical upgrade I also use: if I can estimate size, I’ll pre-size the builder.
StringBuilder sb = new StringBuilder(256);
This is not required, but in hot code it can reduce the number of internal buffer growth steps.
2) String.join and StringJoiner for delimiter-driven joins
If you’re joining known strings with a delimiter, String.join is clean:
public class JoinTags {
public static void main(String[] args) {
String result = String.join(" | ", "billing", "us-east", "v3");
System.out.println(result);
}
}
When you build the list gradually, StringJoiner reads well:
import java.util.StringJoiner;
public class JoinerExample {
public static void main(String[] args) {
StringJoiner joiner = new StringJoiner(", ", "{", "}");
joiner.add("\"service\":\"billing\"");
joiner.add("\"region\":\"us-east\"");
joiner.add("\"version\":\"v3\"");
System.out.println(joiner.toString());
}
}
Two notes from real life:
StringJoineris great when you want a delimiter and optional prefix/suffix (like brackets, braces, parentheses).- If you’re building actual JSON, don’t build JSON strings manually. I only do the above for quick demos or toy formats.
3) Streams: Collectors.joining() when you already have a pipeline
If you’re already using streams, Collectors.joining() is a clean, expressive join.
import java.util.List;
import java.util.stream.Collectors;
public class CollectorsJoining {
public static void main(String[] args) {
List scopes = List.of("read", "write", "admin");
String headerValue = scopes.stream()
.map(s -> "scope=" + s)
.collect(Collectors.joining("; "));
System.out.println(headerValue);
}
}
I don’t force streams just to join strings. But if the data is already in a stream, this avoids manual builder loops.
A Deeper Semantic Difference: Failure vs Degradation
Here’s how I frame the biggest behavioral difference:
concat()makesnulla programming error.+makesnulla string representation.
Neither is universally “better.” The right one depends on whether you want the program to crash early (fail fast) or keep going (degrade gracefully).
When I prefer failing fast
I want a hard failure when the string is acting like an identifier that must be correct:
- cache keys
- database record keys
- message queue routing keys
- cryptographic inputs
- signature bases
- file paths where null would be catastrophic
In those scenarios, I often normalize earlier (validate inputs) and then it doesn’t matter whether I use + or concat(). But if it does matter, I’d rather throw than silently bake "null" into something important.
When I prefer degrading gracefully
I want graceful output when the string is informational and "missing" is acceptable:
- debug logs
- metrics tags (sometimes)
- UI labels (but only with a safe placeholder)
- admin dashboards
In those cases, I still prefer explicit null-handling rather than relying on + to emit "null"—because “null” is rarely the best representation of missing data.
Under the Hood: Why + Often Wins for Readability
In day-to-day Java, + wins because it’s the most direct way to show intent.
Compare these two snippets for a log-like message:
String msg = "userId=" + userId + ", action=" + action + ", ok=" + ok;
Versus:
String msg = "userId="
.concat(String.valueOf(userId))
.concat(", action=")
.concat(action)
.concat(", ok=")
.concat(String.valueOf(ok));
The concat() version is doing more explicit work, but it’s also harder to scan. In code review, I’ve found concat() chains make it easier to miss punctuation, spacing, and delimiters.
So even if you like concat() for its strictness, it tends to lose the ergonomics battle when you’re joining more than two parts.
Common Pitfalls (and How I Avoid Them)
These are the mistakes I most often see in production code when strings are built quickly.
Pitfall 1: Accidental numeric concatenation
You saw the basic precedence example earlier. Here’s the version that sneaks into metrics keys and cache keys:
int major = 1;
int minor = 10;
String version = major + "." + minor; // "1.10" (fine)
String broken = "v" + major + minor; // "v110" (often unintended)
String fixed = "v" + major + "." + minor; // "v1.10"
My rule: if numbers are involved and the format matters, I add parentheses or explicit separators.
Pitfall 2: Hidden expensive toString()
This is a classic one:
String message = "payload=" + payload;
If payload.toString() is expensive, you just paid that cost regardless of whether anyone reads the message.
When I’m logging, I prefer parameterized logging (more on that in a dedicated section), because most logging frameworks can avoid calling toString() when the log level is disabled.
Pitfall 3: Building structured formats by hand
When developers hand-build JSON-ish output with +, it works until it doesn’t:
- missing escaping
- broken quotes
- commas in values
- newlines or tabs
If the string has a formal grammar (JSON, XML, CSV, SQL), I treat string building as a correctness problem, not a style problem.
Pitfall 4: StringBuilder reused across threads
In a rush, people sometimes stash a StringBuilder in a static field to “avoid allocations.” That’s a thread-safety bug.
If you need reuse, consider ThreadLocal very carefully and measure; but most of the time I keep builders method-local and let the JVM do its job.
Practical Scenarios: What I Actually Use in Real Code
This is where the “concat vs +” decision becomes contextual.
Scenario 1: User-facing messages
User-facing output usually has two requirements:
1) it must be correct even with missing values
2) it must be localizable (often)
For internal tools or prototypes, + is fine:
String msg = "Welcome, " + displayName + "!";
But for real products, I avoid stitching user-visible strings with + in many places, because it becomes hard to translate or reorder parts for other languages.
If localization matters, I lean on message templates (framework-dependent) or on MessageFormat as a core-JDK option:
import java.text.MessageFormat;
String msg = MessageFormat.format("Welcome, {0}!", displayName);
It’s not that MessageFormat is perfect—it has its own escaping rules—but it forces me to think in terms of templates instead of ad-hoc concatenation.
Scenario 2: Logging
If I’m using a logging framework that supports parameterized messages (like SLF4J-style {} placeholders), I prefer that over eager concatenation:
logger.debug("userId={}, action={}, ok={}", userId, action, ok);
I reach for this because:
- when debug is disabled, it can avoid doing work
- it keeps the message template readable
- it avoids accidental precedence bugs
I still use + sometimes in logs, but usually only when the logging API doesn’t support parameters or when I’m in throwaway scripts.
Scenario 3: SQL strings (what to do and what not to do)
If you’re ever tempted to do this:
String sql = "SELECT * FROM users WHERE id = " + userId;
don’t. The problem isn’t performance; it’s correctness and security. Use prepared statements.
Even when it’s “just an internal tool,” string-concatenated SQL has a habit of becoming production SQL.
Where concatenation is reasonable in SQL is for static fragments that are not user-controlled and not values:
String base = "SELECT id, name FROM users";
String orderBy = " ORDER BY created_at DESC";
String sql = base + orderBy;
But even then, I try to keep it disciplined: values are parameters; only static structure uses concatenation.
Scenario 4: URLs, file paths, and separators
Simple concatenation works until you hit edge cases like double slashes or missing slashes.
Instead of:
String url = baseUrl + "/v1/users/" + id;
I ask:
- Does
baseUrlalready end with/? - Is
idalready URL-encoded?
If I can, I use a URI builder (library/framework dependent). If I can’t, I normalize:
String normalizedBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String url = normalizedBase + "/v1/users/" + id;
This isn’t about concat() vs +. It’s about handling string joining as a correctness problem when separators matter.
Scenario 5: CSV exports
CSV is deceptively tricky because commas, quotes, and newlines must be escaped.
If you’re sure values have no commas/quotes/newlines, you can build quickly with a builder:
StringBuilder sb = new StringBuilder();
sb.append(orderId).append(‘,‘).append(customerId).append(‘,‘).append(total);
String line = sb.toString();
If you’re not sure (in production you’re rarely sure), use a CSV library. I don’t hand-roll CSV escaping unless it’s a controlled internal format.
Choosing Between concat() and +: My Rules of Thumb
If you want a pragmatic decision framework, this is mine.
I use + when:
- I’m joining a few pieces in one expression
- readability matters more than strictness
- I need automatic conversion of primitives/objects
- I’m building constants (the compiler can fold them)
Example:
String label = "attempt=" + attempt + "/" + maxAttempts;
I use concat() when:
- I want to enforce “string only” at compile time
- I want
nullto throw immediately - I’m joining exactly two strings and want a method call to signal intent
Example:
String full = prefix.concat(suffix);
That said, in many modern codebases, concat() is simply less common because + is clearer for humans and usually optimized well enough.
The Bigger Lesson: Both Are the Wrong Tool in Repeated Appends
If I see either of these patterns in a loop, I stop the review and fix it:
for (...) {
s = s + piece;
}
for (...) {
s = s.concat(piece);
}
The right move is almost always StringBuilder, StringJoiner, or Collectors.joining().
Here’s a concrete example: generating a query string from key/value pairs.
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public class QueryString {
public static String toQueryString(Map params) {
StringJoiner joiner = new StringJoiner("&");
for (Map.Entry entry : params.entrySet()) {
String key = URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8);
String value = entry.getValue() == null ? "" : URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8);
joiner.add(key + "=" + value);
}
return joiner.toString();
}
}
Notice what I did there:
- I used
StringJoinerbecause delimiters matter. - I still used
+for the small, local join ofkey + "=" + value. - I handled
nullexplicitly as an empty value.
This is typical of how I write real code: I choose the tool per layer.
A Note on Readability: Intent Beats Micro-Optimizations
I’ll be blunt: most concat-vs-plus debates waste time.
I care about:
1) correctness (null handling, separators, escaping)
2) clarity (can a teammate scan it quickly?)
3) avoiding pathological allocation patterns (loops)
If + communicates intent more clearly, I choose +. If strictness matters, I choose stricter constructs (validation, non-null types, or concat()), but I don’t use concat() purely as a “performance hack.”
FAQ (Real Questions I’ve Been Asked)
Is concat() faster than +?
In most real code, not in a way that matters. For a few pieces in a single expression, + is usually compiled efficiently. concat() is a method call and can also be optimized by the JVM, but it doesn’t magically avoid allocations.
The performance difference that does matter is loop behavior: repeated concatenation with either approach creates lots of intermediate strings. Use StringBuilder or a join API.
Why does + print "null"?
Because when String concatenation happens, Java converts the other operand to a string representation. For object references, String.valueOf(Object) returns "null" when the object is null.
Why does concat() throw NullPointerException?
Because concat() is defined as appending another string, and a null argument is not a valid String. Its contract is strict and it fails immediately.
Should I ban concat() or ban + in code review?
I wouldn’t. What I do enforce:
- no repeated concatenation in loops
- explicit handling for missing values in user-facing text
- parameterized logging instead of eager concatenation
- correct escaping for structured formats
If your team wants consistency, pick one for simple cases (most teams pick +) and document exceptions.
Final Takeaways
If you remember nothing else:
String.concat()is strict: it only acceptsString, andnullthrows.+is flexible: it converts primitives/objects and turnsnullinto the text"null".- In loops or iterative building, both are the wrong tool—use
StringBuilder,StringJoiner, orCollectors.joining().
When I treat string joining as a real design decision—rather than “just glue”—I end up with code that’s more correct, easier to read, and less likely to surprise me at 2 a.m.


