Java List Join — SQL Injection Via Unescaped Comma Values
One unescaped quote in a comma-joined list drops database tables.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- String.join(", ", list) – simplest for List
with no null handling. - Collectors.joining() – stream-friendly, supports prefix/suffix, but throws on null.
- StringBuilder loop – full control, works pre-Java 8, best for complex conditions.
- Filter nulls first: failing to do so turns 'null' into literal text.
- Performance: Collectors.joining() uses StringBuilder internally, nearly identical speed.
- Production trap: using join for SQL IN clause without parameterisation invites injection.
You have a list of values and need them as a single string with commas between them — for a CSV, a log message, an SQL IN clause, or an API parameter. Java gives you several ways and the right choice depends on how much control you need over the delimiter, prefix, and suffix.
List to CSV string is one of those operations that looks trivial and has a surprising number of correct answers depending on your requirements. String.join() for the simple case. Collectors.joining() for stream pipelines. StringBuilder for complex transformation requirements or pre-Java 8 codebases. But you'll burn your first deployment if you assume all three behave the same with nulls, special characters, or large lists.
Why Joining a List to a Comma String Is a Security Boundary
Joining a Java List to a comma-separated string is the trivial operation of concatenating elements with a delimiter, typically using String.join(",", list) or Collectors.joining(","). The core mechanic is straightforward: iterate the list, append each element, and insert commas between them. But this simplicity masks a dangerous assumption — that the resulting string will be safely parsed back into the same list. When the list contains values that themselves include commas, the round-trip is broken: "a,b" becomes "a,b" and splits back into ["a", "b"], not ["a,b"]. This is O(n) in time and O(n) in space, but the real cost is semantic corruption.
In practice, the join operation is stateless and delimiter-agnostic — it does not escape or quote values. This means any downstream parser that splits on the same delimiter will misinterpret embedded commas. The problem compounds when the joined string is used in SQL IN clauses, CSV generation, or HTTP query parameters. For example, building a SQL WHERE id IN ('a,b') from a list containing "a,b" produces a syntax error or, worse, injects unintended values. The join itself is correct; the vulnerability is in assuming the delimiter is never a data character.
Use list-to-comma-join only when you control both the list contents and the parsing context — for example, logging a debug string or constructing a non-critical label. Never use it to build SQL queries, CSV rows, or any format where the delimiter can appear in the data. The moment you join a list for a downstream parser, you have introduced a potential injection point. The safe alternative is parameterized queries or proper escaping (e.g., CSV quoting, JSON arrays).
String.join(), Collectors.joining(), and StringBuilder
Three approaches cover all real-world cases. The Java 8+ stream API version (Collectors.joining()) is the most flexible and composes well with filtering and transforming the list before joining. But here's the thing: they handle nulls differently, and that difference has burned me more than once in production. String.join() calls toString() on each element – if the element is null, it appends the literal string "null". Collectors.joining() throws NullPointerException the moment it encounters a null. StringBuilder gives you full control – you decide what to append.
For a simple List<String> where you know there are no nulls, String.join() is the clear winner. For stream pipelines that already filter or map, stick with Collectors.joining(). For everything else – especially when you need to conditionally skip or transform elements – reach for StringBuilder.
String.join() converts null to "null" silently – you won't notice until someone reports a CSV with the word 'null' in it.Collectors.joining() throws NPE on the first null – you'll catch it in staging, but then have to add a filter.String.join() is for trusted, non-null List<String>.Collectors.joining() is for stream pipelines that already filter.Handling Nulls and Empty Elements
Most production lists contain nulls. Maybe a database query returned a null column. Maybe an API response had a missing field. You need to decide what to do with them. The three joining methods give different default behaviours, and none of them are what you want out of the box.
String.join() calls toString() on null – you get "null" in your output. That's almost never correct for a CSV or log message. Collectors.joining() throws a NullPointerException – at least you'll know something is wrong, but it crashes the whole operation. The StringBuilder approach lets you check for null before appending.
The safest pattern: filter nulls before you join. For String.join(), do list.stream().filter(Objects::nonNull).collect(Collectors.toList()) then join. For streams, add .filter(Objects::nonNull) in the pipeline. If you want to replace nulls with empty strings or a placeholder, use .map(e -> e == null ? "" : e).
Empty strings are trickier. String.join() treats them as valid elements – you'll get "value1,,value3" with an extra comma. Decide whether to filter out blanks as well using !s.isEmpty() or !s.isBlank().
String.join() hides nulls; Collectors.joining() reveals them via NPE.Performance: When Each Approach Wins
For small lists (a few hundred elements), all three methods are effectively free. The difference shows up when you join lists with millions of elements or in tight loops inside a hot code path.
String.join() delegates to a StringBuilder internally – it allocates a builder with an initial capacity equal to the sum of element lengths plus the delimiter overhead. Same story for Collectors.joining() – it uses a StringBuilder under the hood. There's no magic performance advantage for typical use cases.
Where you can lose performance: repeated string concatenation in a loop using + or += concatenation. That's O(n²) because each concat creates a new String. Always use StringBuilder for loops.
Another subtle performance trap: calling String.join() inside a loop that rebuilds the same list multiple times. If you're joining the same list multiple times (e.g., for each log entry), cache the result.
For huge lists, pre-allocating StringBuilder capacity is the single biggest optimisation. If you estimate the final size wrong, StringBuilder will resize multiple times, each time copying the entire buffer.
Benchmark your specific workload. But in 99% of real applications, the choice of joining method doesn't affect performance – what matters is how often you join and how large the list is.
String.join() or Collectors.joining().Building Complex Strings with Custom Delimiters, Prefixes, and Suffixes
Sometimes you don't just want commas between values. You need brackets, quotes, or a custom wrapper. Collectors.joining(delimiter, prefix, suffix) handles this elegantly. String.join() does not support it. StringBuilder gives you full control but requires more code.
- JSON array: join with ", " and prefix "[" and suffix "]".
- SQL IN clause with quoted strings: join with "','" and prefix "('" and suffix "')". But again – never use this for dynamic SQL.
- Log messages wrapping service names: join with "|" and prefix "[services: " and suffix "]".
If you need different handling for the first and last elements (e.g., no delimiter before the first, or a different separator between the last two), you need a loop. Collectors.joining() can't do Oxford commas or conditional separators. That's when you fall back to StringBuilder with explicit index checks.
- Collectors.joining(delimiter, prefix, suffix): uniform treatment.
- Loop with if-else on index: irregular treatment (first/last special).
- Streams can't express 'no delimiter before first' because it's built-in.
- Pick the tool that matches the uniformity of your format.
Collectors.joining() handles that for you; a loop requires explicit index checks.Collectors.joining() for uniform delimiters; use a loop only when you need conditional formatting.Common Pitfalls in Production: Large Lists, Memory, and Injection
Joining lists sounds safe, but I've seen three production outages caused by it. First: memory. A list with millions of elements gets joined into a single string that consumes hundreds of megabytes. If the JVM heap isn't sized for it, you crash with OutOfMemoryError. Second: SQL injection. Using join to build IN clauses from user-supplied values – the classic mistake. Third: character encoding. When joining strings that contain Unicode characters, the default charset might not handle them correctly when writing to a file or network stream.
- For large lists, consider writing directly to an OutputStream (e.g., CSV export) instead of building one enormous string.
- For SQL, use PreparedStatement with setArray or repeated placeholders.
- For encoding, always specify the charset explicitly when converting to bytes.
- Also watch out for delimiter characters inside the data. If your list contains strings with commas, a simple comma join produces broken CSV. Either escape the commas, or use a different delimiter.
The Third-Party Library Trap: Apache Commons, Guava, and Spring
Most senior devs have seen a codebase where someone pulled in Apache Commons Lang just to call StringUtils.join(). That's a dependency for a one-liner. But here's where it gets real: if you're already carrying Spring or Guava, their join utilities come with zero extra weight.
Spring's StringUtils.collectionToCommaDelimitedString() handles null collections gracefully - it returns empty string instead of a NullPointerException. Guava's Joiner class is the sharpest tool here. It's more verbose upfront, but Joiner.skipNulls() and Joiner.useForNull() give you explicit control over null handling that the standard library can't match.
The WHY: When your list contains nulls - and production lists always do - Guava's Joiner makes the contract visible in code. String.join() just throws. Collectors.joining() silently concatenates null literals. Choose your poison based on your null policy, not your dependency guilt.
Don't add a library just for this. But if you're already carrying one, use its join utility. You're already paying the tax.
StringUtils.join() adds ~130KB to your deployment unit. That's a junior move. Measure before you add.The Parallelism Trap: Why Stream.parallel() Will Burn You
You think you're clever. You've got a list with 50 million customer IDs and you want to join them fast. So you write list.parallelStream().collect(Collectors.joining(",")). Bam.
Here's why that's a disaster: String concatenation is inherently serial. Behind the scenes, Collectors.joining() uses StringBuilder. When you parallelize, you're creating multiple StringBuilder instances, joining their partial results, then merging. The merge operation itself involves string copying. You're adding overhead, not removing it.
Benchmarks show that for lists under ~100,000 elements, parallel() is 3-5x slower than sequential. For larger lists, the overhead of thread coordination and StringBuilder merging means you rarely see more than a 20% speedup on a 16-core machine. The memory explosion from keeping partial results? Worse. Each thread allocates its own backing array.
The fix: If you really need parallelism for a join operation, pre-partition the list manually, join each chunk in parallel, then do a final join of the chunks. But honestly? Just use String.join(). It's optimized in native code and will beat anything you hand-roll.
Don't parallelize joins. It's the most common micro-optimization that makes things worse.
String.join() is native, single-threaded, and optimized at the JVM level. It beats parallel streams every time for join operations. When in doubt, benchmark. Your intuition about parallelism is probably wrong.String.join() is faster than any parallel stream implementation due to JVM-level optimizations and the inherent serial nature of concatenation.StringJoiner: The Overlooked Utility That Should Be Your Default
Most devs reach for String.join() or stream collectors without knowing Java already gave you a dedicated class for joining strings: StringJoiner. It was added in Java 8 and sits right in java.util. You don't need Apache Commons. You don't need Guava.
StringJoiner gives you a delimiter, prefix, and suffix in the constructor. No string concatenation in loops. No StringBuilder boilerplate. The real win? It handles the fencepost problem automatically — your delimiter never appears before the first element or after the last.
Production reality: StringJoiner is faster than Collectors.joining() because it avoids stream overhead. It's safer than manual loops because you can't forget to check isEmpty(). If you're building comma-separated strings from any list, make StringJoiner your default. It's the tool Java gave you for this exact job.
Conclusion: Stop Writing Joining Logic From Scratch
You now have three battle-tested approaches: StringJoiner for straightforward joins, Collectors.joining() for stream pipelines, and String.join() for varargs. Stop hand-rolling loops with StringBuilder. Stop importing Guava just for Joiner. Stop treating this as a problem that needs a library.
The decision tree is simple: single delimiter without null handling? Use String.join(). Already have a stream? Use Collectors.joining(). Everything else — nulls, prefixes, suffixes, performance — your default is StringJoiner.
One final warning: never join user-controlled input into SQL queries or shell commands, no matter which method you pick. The joining logic isn't the vulnerability — it's what you do with the result. Sanitize after joining, or better yet, use parameterized queries. The comma string is for display and logs, not for injection points.
Overview: Why Java List-to-Comma Conversion Deserves More Attention
Converting a Java List to a comma-separated String appears trivial—a simple loop with a StringBuilder, right? Yet this operation is a frequent source of production bugs, security vulnerabilities, and performance regressions. Every day, developers write ad-hoc joining logic that mishandles null elements, produces malformed CSV output, or exhausts heap memory on large datasets. The real challenge isn't the joining itself; it's understanding which tool fits your context: String.join() for single-delimiter simplicity, Collectors.joining() for stream pipelines, or StringJoiner for explicit control. The wrong choice can introduce injection vectors when composing SQL queries, log entries, or API parameters. This article dissects each approach, exposing hidden costs and edge cases. You'll learn why joining is a security boundary, how nulls corrupt output silently, and why third-party libraries often add complexity without benefit. By the end, you'll have a deterministic, memory-safe rule for every scenario—and stop reinventing this wheel.
String.join() or naive loops. Always filter or map nulls before joining.Security Boundaries: Why Joining Must Be Context-Aware
Every join operation is an implicit trust boundary. When you convert a List to a comma-separated String, you're serializing untrusted user input into a format that will be parsed by another system—SQL, CSV, log aggregators, or HTTP APIs. A single comma injected into an element can shift columns; a double quote can break CSV parsers; a semicolon can alter SQL semantics. This is why joining is not a formatting concern—it's a security boundary. Java's Collectors.joining() and StringJoiner apply only the delimiter you specify; they do not sanitize or escape. For CSV output, you must wrap each value in quotes and escape internal quotes and commas. For SQL IN clauses, use parameterized queries, never concatenate joined strings. The naive assumption that comma-separated values are safe leads to injection vulnerabilities in endpoints, file exports, and audit logs. Treat every join as a data-leaving context; apply escaping or validation before joining, not after. Libraries like Apache Commons CSV exist precisely because raw joining is inadequate for structured output.
SQL Injection Through a Comma-Joined List
String.join() does not escape anything.- SQL injection is the one failure that cannot be prevented by string joining alone.
- Always treat any list that touches user input as untrusted — use parameterised queries.
- If you must build a comma-separated string from trusted internal data, still validate every element for unexpected characters.
list.stream().filter(Objects::nonNull) before joining, or use Collectors.joining() with a custom collector that skips nulls.list.size() * avgElementLength)). Avoid String.join() on large lists in a loop without reusing the builder.list.stream().filter(Objects::nonNull).collect(Collectors.joining(", "))list.replaceAll(s -> s == null ? "" : s); String result = String.join(", ", list);Key takeaways
Collectors.joining() is more flexible and integrates into stream pipelines.Common mistakes to avoid
5 patternsNot filtering nulls before joining
list.stream().filter(Objects::nonNull) before joining, or map nulls to empty strings with .map(s -> s == null ? "" : s).Building SQL IN clauses with string join from user input
Using Collectors.joining() without handling nulls
Inefficiently concatenating strings in a loop with +=
Not handling delimiter characters inside values when generating CSV
Interview Questions on This Topic
How would you convert a List
list.stream().map(String::valueOf).collect(Collectors.joining(", ")). This maps each integer to its string representation, then joins with a comma and space.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Strings. Mark it forged?
10 min read · try the examples if you haven't