Java List Join — SQL Injection Via Unescaped Comma Values
One unescaped quote in a comma-joined list drops database tables.
- 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.
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.
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.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
That's Strings. Mark it forged?
4 min read · try the examples if you haven't