I still see production bugs caused by one tiny choice: returning null instead of an empty list. It looks harmless during initial development, yet it shows up later as a surprise NullPointerException in a part of the codebase nobody expects. That is why I reach for Collections.emptyList() so often. It gives me a safe, explicit empty list, and it communicates intent to anyone reading my code. You get a list with no elements, and you also get immutability, which is critical when you want to guarantee that no caller can mutate your return value. In this post I walk through how emptyList() behaves, why immutability matters, and how to use it in real systems. You will see runnable examples, a deliberate exception example, common mistakes, performance notes, and guidance on when to use it versus other empty list options. I also show patterns that fit modern 2026 workflows, including AI-assisted code reviews and static analysis, without making the topic feel heavy.
What emptyList() actually returns
Collections.emptyList() returns a list with zero elements. The list is immutable, meaning any attempt to add, remove, or replace elements throws UnsupportedOperationException at runtime. It is also a singleton instance internally, so repeated calls return the same empty list object rather than allocating a new list each time. In practice, you can treat it as a safe, shared empty list that costs almost nothing to use.
From a developer perspective, the key points are:
- It is a List, so it works anywhere a List is expected.
- It has no elements, so size() is always 0 and isEmpty() is always true.
- It is immutable, so callers cannot change it.
- It is generic, so you can get type safety with Collections.emptyList().
If you only remember one thing, remember this: emptyList() is a signal. It signals to readers and callers that “this list is intentionally empty and must stay that way.” I use that signal in APIs, caches, and internal service layers to avoid bugs and avoid wasteful allocations.
Immutability and why it matters
Immutability is more than a preference; it is a guarantee. When I return a list from a service method, I am making a promise about how safe it is to handle that data. If I return a mutable list, any caller can change it, intentionally or accidentally. That can lead to subtle bugs, especially when that list is shared or cached.
With emptyList(), the promise is strict. If someone tries to mutate the list, they get a fast failure. That failure is valuable because it moves the error to the exact line that made the bad assumption. Think of it like giving someone a sealed container. If they try to pry it open, they will immediately see that it is sealed, instead of quietly altering the contents and causing a mess later.
In my experience, immutable empty lists are most helpful in these scenarios:
- Public API return values where you want to prevent caller mutation.
- Cache results that should be shared across requests.
- Default parameters in constructors or builders.
- Defensive programming in domain models.
The main tradeoff is obvious: you cannot add to the list after creation. If you need a list that starts empty and grows, you should use a mutable list instead, such as new ArrayList(), and then consider wrapping it in an unmodifiable view only when you return it.
Basic usage with generics (Example 1)
Here is a minimal example that creates and prints an empty list. I include a comment only where the intent might be unclear. The code is runnable as-is.
import java.util.Collections;
import java.util.List;
public class EmptyListBasic {
public static void main(String[] args) {
// Explicit type parameter for clarity in older compilers
List tags = Collections.emptyList();
System.out.println(tags);
}
}
Output:
[]
That printout looks simple, yet it carries strong meaning. The list is empty and immutable. If you use modern Java, you can let type inference do the work:
import java.util.Collections;
import java.util.List;
public class EmptyListInferred {
public static void main(String[] args) {
List ids = Collections.emptyList();
System.out.println(ids.size());
}
}
I usually prefer the inferred version because it is clean and still type-safe. If you are passing the empty list to a method with a generic parameter, you may need the explicit type parameter to keep the compiler happy. That is a small price for clarity.
What happens when you try to modify (Example 2)
The second example shows the exact failure you should expect when you try to add elements. This is not a problem with emptyList(); it is the intended behavior.
import java.util.Collections;
import java.util.List;
public class EmptyListMutation {
public static void main(String[] args) {
List numbers = Collections.emptyList();
// This will throw UnsupportedOperationException
numbers.add(1);
}
}
Typical output:
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
at EmptyListMutation.main(EmptyListMutation.java:9)
I like this example because it demonstrates the failure mode clearly. You are not getting a silent no-op; you are getting a runtime exception with a stack trace pointing to the exact line. If you see this in a codebase, your fix is usually to replace emptyList() with a mutable list, or to refactor the caller so it does not try to mutate a list it did not create.
When to use it and when to avoid it
I recommend emptyList() when your list is a result, a default, or a shared value. I avoid it when the list is a working container that must be mutated.
Use it in these cases:
- Return values: If no elements exist, return Collections.emptyList() instead of null.
- Defaults: If a configuration has no overrides, store emptyList() as the default.
- Shared values: If you cache the empty response for a query, use emptyList() so nothing changes it.
- Public APIs: If you do not want callers to change internal data, return emptyList() or a copy.
Avoid it in these cases:
- Builders or aggregators that need to add items over time.
- Methods that expect to mutate the list parameter.
- Any code where a list must be sorted or replaced in-place.
A simple rule of thumb: if the list is the final answer, emptyList() is a great fit. If the list is a workbench, use a mutable list and then return an immutable view when you are done.
Real-world patterns and edge cases
Here are patterns I use in production, with complete runnable examples and minimal, meaningful comments.
Safe return from a service
import java.util.Collections;
import java.util.List;
class OrderService {
public List findOrderIdsByCustomer(String customerId) {
if (customerId == null || customerId.isBlank()) {
return Collections.emptyList();
}
// Pretend query result is empty
return Collections.emptyList();
}
}
public class EmptyListServiceDemo {
public static void main(String[] args) {
OrderService service = new OrderService();
List ids = service.findOrderIdsByCustomer("");
System.out.println(ids.isEmpty());
}
}
Defensive constructor defaults
import java.util.Collections;
import java.util.List;
class ReportConfig {
private final List columns;
public ReportConfig(List columns) {
this.columns = (columns == null) ? Collections.emptyList() : columns;
}
public List getColumns() {
return columns;
}
}
public class EmptyListConfigDemo {
public static void main(String[] args) {
ReportConfig config = new ReportConfig(null);
System.out.println(config.getColumns().size());
}
}
API boundary with immutable return
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class AuditLog {
private final List entries = new ArrayList();
public void record(String entry) {
entries.add(entry);
}
public List getEntries() {
if (entries.isEmpty()) {
return Collections.emptyList();
}
return Collections.unmodifiableList(entries);
}
}
public class EmptyListAuditDemo {
public static void main(String[] args) {
AuditLog log = new AuditLog();
System.out.println(log.getEntries());
}
}
Edge cases to keep in mind:
- Serialization: emptyList() serializes as an empty list, which is fine, but if you rely on specific list implementations, be explicit.
- APIs expecting mutable lists: if a framework tries to add elements to the list you pass in, emptyList() will fail. I often see this with older JSON or ORM libraries.
- Type inference in method calls: if the compiler cannot infer the type, call Collections.emptyList() explicitly.
Performance and memory behavior
From a performance perspective, emptyList() is almost always faster and lighter than new ArrayList(), because it returns a shared instance. In hot paths, that avoids per-call allocations and reduces GC pressure. I use it freely in small helper methods and in frequently called service methods.
That said, immutability is the real performance win. A list you do not mutate is easier to reason about and safer to share across threads. You avoid synchronization and defensive copies. In multi-threaded systems, this is huge. I once traced a concurrency issue to a shared mutable list that was quietly changed by a caller; switching to emptyList() for the empty case and an unmodifiable list for the non-empty case eliminated the bug and reduced CPU overhead from redundant copying.
Typical performance characteristics you can expect in practice:
- Allocation cost: near zero for emptyList() because it is a shared instance.
- Access cost: constant time for size() and isEmpty().
- Mutation cost: immediate failure, which is good because it fails fast.
If you need a list that can grow, do not fight emptyList(). Create a mutable list, add elements, then wrap it in Collections.unmodifiableList() before returning it. This yields safety without sacrificing flexibility.
Common mistakes and how I avoid them
These are the mistakes I see most often when teams adopt emptyList(). I call them out in reviews, and I fix them early to prevent surprises.
1) Trying to add to the returned list
If you plan to add elements, start with a mutable list:
import java.util.ArrayList;
import java.util.List;
public class MutableStartDemo {
public static void main(String[] args) {
List values = new ArrayList();
values.add(42);
System.out.println(values);
}
}
2) Returning null instead of empty list
Null creates conditional code at every call site. I prefer to return emptyList() and make the caller logic cleaner:
import java.util.Collections;
import java.util.List;
class Catalog {
public List findTags(String productId) {
return Collections.emptyList();
}
}
3) Assuming it is safe to cast
emptyList() returns a List, but it is not an ArrayList. Avoid implementation-specific casts. Treat it as a List and keep interfaces in your method signatures.
4) Forgetting type parameters in tricky inference cases
If you see a compile error with generics, give the type explicitly:
import java.util.Collections;
import java.util.List;
public class ExplicitTypeDemo {
public static void main(String[] args) {
List ids = Collections.emptyList();
System.out.println(ids);
}
}
Traditional vs modern patterns in 2026
By 2026, many teams use AI-assisted code review, static analysis, and modern IDE tooling. That affects how I recommend emptyList() usage, because the tools can enforce better defaults and catch incorrect mutations early.
Here is a simple comparison of older habits versus modern, safety-first patterns.
Modern approach (2026)
—
Return Collections.emptyList()
Use mutable lists internally, immutable at boundaries
Combine review with static analysis and AI checks
Block mutation with emptyList() or unmodifiableList()In my daily work, I pair emptyList() with tools like static analyzers that flag nullable returns and mutability issues. AI-assisted review is especially useful for spotting places where a mutable list slips into a public API. That keeps contracts consistent and reduces the volume of “defensive copy” code I have to write.
Practical guidance for choosing the right empty list
You may be wondering whether to use Collections.emptyList() or List.of() (introduced in newer Java releases). Both are immutable and suitable for empty lists, but they are used in different styles. I still reach for Collections.emptyList() in library-like code and in legacy-compatible codebases because it has a long history and clear intent. I use List.of() when I want a quick literal, especially when constructing small, fixed lists in-line.
Here is a quick decision guide I follow:
- Need an empty list as a default or return value? Use Collections.emptyList().
- Need a fixed list with known elements? Use List.of("a", "b").
- Need a list that you will mutate? Use new ArrayList().
If you are building APIs that may be consumed by different teams or services, I recommend the Collections.emptyList() choice for the empty case because it makes the intent explicit and fits older code without extra dependencies.
Deep dive: how emptyList() behaves with generics and method overloads
Generics are where emptyList() really shines, but they can also be the source of confusing compiler errors. The list is generic and type-safe, yet the compiler sometimes needs help when it cannot infer the target type. This usually happens when you pass emptyList() into an overloaded method or when the generic type is only on the return value.
Consider this overload example:
import java.util.Collections;
import java.util.List;
public class OverloadDemo {
static void accept(List s) {
System.out.println("Strings: " + s.size());
}
static void accept(List i) {
System.out.println("Integers: " + i.size());
}
public static void main(String[] args) {
// This is ambiguous without a type hint
// accept(Collections.emptyList());
accept(Collections.emptyList());
accept(Collections.emptyList());
}
}
The compiler can resolve it once you specify the generic type. I keep this pattern in mind whenever I see “reference to accept is ambiguous” errors.
Another common inference issue occurs with factory methods or builder chains:
import java.util.Collections;
import java.util.List;
class Page {
private final List items;
private Page(List items) {
this.items = items;
}
public static Page of(List items) {
return new Page(items);
}
}
public class PageDemo {
public static void main(String[] args) {
// Explicit type lets the compiler bind T
Page page = Page.of(Collections.emptyList());
System.out.println(page);
}
}
I mention these because they are practical pitfalls in large codebases with many overloaded methods or generic factories. The fix is straightforward: add the type parameter or use a typed variable first.
emptyList() vs null: a concrete API contract example
I like to show a side-by-side example because it makes the contract difference obvious. Imagine a method that searches for matching usernames:
import java.util.Collections;
import java.util.List;
class UserDirectory {
public List findUsernames(String query) {
if (query == null || query.isBlank()) {
return Collections.emptyList();
}
// Mock query response
return Collections.emptyList();
}
}
public class ContractDemo {
public static void main(String[] args) {
UserDirectory directory = new UserDirectory();
for (String name : directory.findUsernames("")) {
System.out.println(name);
}
System.out.println("No errors, no null checks.");
}
}
The caller can use the list in a for-each loop without worrying about null. That is the core benefit: the contract is simple and reliable. In a large codebase, this eliminates whole categories of conditional checks and reduces cognitive load.
Defensive copying and emptyList()
There is a subtle but important relationship between defensive copying and emptyList(). If you accept a list from the outside and you do not want callers to mutate your internal state, you typically copy or wrap. For empty lists, copying is a waste, and wrapping can be redundant. That is where emptyList() shines: it gives you the “safe by default” contract with zero overhead.
Here is a constructor pattern I use often:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class NewsletterConfig {
private final List topics;
public NewsletterConfig(List topics) {
if (topics == null || topics.isEmpty()) {
this.topics = Collections.emptyList();
} else {
this.topics = Collections.unmodifiableList(new ArrayList(topics));
}
}
public List getTopics() {
return topics;
}
}
This is a clear, safe contract:
- If the caller passes nothing, I store a shared immutable empty list.
- If the caller passes items, I copy them and then lock them down.
In code review, this stands out as intentional and secure, which is exactly what I want when building APIs that live for years.
Interoperability with legacy frameworks
Not every library expects an immutable list. Some older frameworks or utilities will accept a List and then attempt to mutate it (for example, adding defaults or converting it in-place). If you pass Collections.emptyList() to such APIs, you will get UnsupportedOperationException.
Here is the pattern I follow to handle that cleanly:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class LegacyAdapter {
// Pretend this legacy API mutates the list
public List normalize(List input) {
List working = new ArrayList(input);
working.add("default");
return working;
}
}
public class LegacyDemo {
public static void main(String[] args) {
LegacyAdapter adapter = new LegacyAdapter();
List safeInput = Collections.emptyList();
List result = adapter.normalize(safeInput);
System.out.println(result);
}
}
The idea is simple: when you cross a boundary into an API you do not control, provide a mutable copy. That lets you keep immutability internally while still working with older code. In 2026, I see this especially in legacy XML or ORM layers, where mutation is assumed.
emptyList() and concurrency safety
Shared mutable lists are a classic source of concurrency bugs. If two threads share a list and one thread mutates it, you can get inconsistent behavior, exceptions, or data races. emptyList() is immutable and safe to share across threads without synchronization.
I use this pattern for caches:
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
class ProductCache {
private final Map<String, List> cache = new ConcurrentHashMap();
public List findTags(String productId) {
return cache.getOrDefault(productId, Collections.emptyList());
}
public void putTags(String productId, List tags) {
if (tags == null || tags.isEmpty()) {
cache.put(productId, Collections.emptyList());
} else {
cache.put(productId, Collections.unmodifiableList(tags));
}
}
}
This gives me thread safety without additional locks for empty cases. The list is shared safely, and because it is immutable, I do not worry about accidental changes. For non-empty lists, I still wrap them to keep the same contract.
emptyList() in functional and stream-based code
Java streams make it easy to flow from a query to a list. When a stream pipeline yields no results, it returns an empty list anyway. But emptyList() still matters in the “no data” branch or when you must return something without running a pipeline.
Here is a pattern I use in service methods:
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
class UserService {
public List activeUsers(List users) {
if (users == null || users.isEmpty()) {
return Collections.emptyList();
}
return users.stream()
.filter(u -> u.startsWith("A"))
.collect(Collectors.toList());
}
}
Note that collect(Collectors.toList()) returns a mutable list. If the list is part of an API boundary, I typically wrap it or convert it to an immutable list after collecting. If the result is empty, I still prefer returning emptyList() because it communicates intent and avoids the cost of a new list allocation in the empty case.
Practical scenario: paginated APIs
Pagination is a place where emptyList() helps with clarity. When a page has no items, the response still contains a list, just empty. This keeps the API consistent.
import java.util.Collections;
import java.util.List;
class Page {
private final List items;
private final int page;
private final int size;
public Page(List items, int page, int size) {
this.items = items;
this.page = page;
this.size = size;
}
public List getItems() { return items; }
public int getPage() { return page; }
public int getSize() { return size; }
}
class SearchService {
public Page search(String query, int page, int size) {
if (query == null || query.isBlank()) {
return new Page(Collections.emptyList(), page, size);
}
// Mock empty results
return new Page(Collections.emptyList(), page, size);
}
}
public class PaginationDemo {
public static void main(String[] args) {
SearchService service = new SearchService();
Page result = service.search("", 0, 10);
System.out.println(result.getItems().isEmpty());
}
}
This pattern keeps JSON responses consistent and avoids null handling on the client side. In my experience, front-end teams appreciate this because it reduces edge case code in the UI.
Practical scenario: aggregation pipelines
Another real-world case is aggregating data from multiple sources. Some sources may return no items, and emptyList() ensures your aggregation logic stays simple.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class Aggregator {
public List mergeSources(List a, List b) {
List result = new ArrayList();
if (a != null) result.addAll(a);
if (b != null) result.addAll(b);
return result.isEmpty() ? Collections.emptyList() : result;
}
}
You return a mutable list when it has content, but return emptyList() when it does not. If this is an API boundary, I would wrap the non-empty result in Collections.unmodifiableList(result) to preserve immutability. The empty case is already safe.
Performance considerations with ranges, not exact numbers
I avoid claiming specific millisecond wins because every environment is different. But the general performance profile is consistent:
- Allocation cost: emptyList() is effectively zero allocations, while new ArrayList() is a new object each call.
- Throughput: in hot paths with many empty results, emptyList() can reduce GC churn noticeably, often by a low single-digit percentage in real services.
- Latency: less allocation pressure can smooth tail latency, especially when empty results are common.
If you benchmark, you will usually see that a shared immutable empty list reduces allocations and provides more stable performance under load. The difference is small per call but accumulates in large services.
Alternative approaches and how I decide
There are multiple “empty list” options in Java. Here is a quick comparison and the way I choose:
- Collections.emptyList(): Best for explicit empty list defaults and return values; singleton; available in older Java versions.
- List.of(): Great for concise, modern literals; immutable; use when you want to express a fixed list inline.
- new ArrayList(): Use when you need to build up a list; mutable; costs an allocation.
- Collections.unmodifiableList(list): Use to return a read-only view of an existing list; still depends on the mutability of the backing list.
When I build libraries or shared modules, I default to Collections.emptyList() because it is universally understood and compatible. When I build simple apps with newer Java versions, I might use List.of() for readability. The key is to keep your contract consistent in one codebase so developers know what to expect.
Error handling and intentional exceptions
Some developers dislike the fact that emptyList() throws UnsupportedOperationException on mutation. I see that as a feature, not a flaw. It is a fast failure that signals a bad assumption. If a caller tries to mutate a list returned by a service, that caller is breaking the contract. The exception forces them to stop and fix it.
However, if you want to create a list that can be modified by the caller, you can return a new ArrayList() instead. That is a different contract. The key is to choose the contract that matches your intent and document it through your method name, return type, and usage patterns.
Writing tests that lock in the behavior
I often include tests that make the contract explicit. This is especially important for public APIs, because it prevents regressions when refactoring.
import java.util.Collections;
import java.util.List;
class TestTarget {
public List getItems() {
return Collections.emptyList();
}
}
public class EmptyListTestStyle {
public static void main(String[] args) {
TestTarget t = new TestTarget();
List items = t.getItems();
if (!items.isEmpty()) {
throw new AssertionError("Expected empty");
}
try {
items.add("x");
throw new AssertionError("Mutation should fail");
} catch (UnsupportedOperationException ok) {
System.out.println("Immutable as expected");
}
}
}
These tests are simple and effective. They document intent and prevent future changes from unintentionally returning mutable lists.
Modern tooling: static analysis and AI-assisted review
In 2026, most teams have static analysis rules that discourage null returns and mutable collections in public APIs. I typically add or enable rules that:
- Flag methods returning List that can return null.
- Suggest using emptyList() instead of null in conditional branches.
- Identify methods returning mutable lists from immutable domain objects.
AI-assisted review complements these rules. It can catch subtle cases like “returning a mutable list from a public API” or “mutating a list from a dependency.” I have seen these tools prevent bugs before they hit production. emptyList() is an easy fix when those suggestions appear, which is another reason I like it.
Migration strategy for existing codebases
If you want to adopt emptyList() at scale, here is a safe and practical approach I have used:
1) Replace null returns with emptyList() in leaf methods (methods that do not call other methods).
2) Update call sites to remove null checks and simplify control flow.
3) Add tests that assert immutability or the absence of null.
4) Add static analysis rules to prevent regressions.
This avoids breaking changes because callers that already handle null can continue to work. Over time, you can remove redundant null checks and simplify your code.
Practical patterns you can copy today
I keep a few patterns ready to copy because they solve real problems quickly:
Pattern: safe default in a builder
import java.util.Collections;
import java.util.List;
class QueryOptions {
private final List filters;
private QueryOptions(List filters) {
this.filters = filters;
}
public static QueryOptions of(List filters) {
if (filters == null || filters.isEmpty()) {
return new QueryOptions(Collections.emptyList());
}
return new QueryOptions(Collections.unmodifiableList(filters));
}
public List getFilters() {
return filters;
}
}
Pattern: return immutable list from a repository
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class Repo {
public List findAll() {
List items = new ArrayList();
// Pretend we loaded items here
if (items.isEmpty()) {
return Collections.emptyList();
}
return Collections.unmodifiableList(items);
}
}
Pattern: avoid null in DTOs
import java.util.Collections;
import java.util.List;
class UserDto {
private final List roles;
public UserDto(List roles) {
this.roles = roles == null ? Collections.emptyList() : roles;
}
public List getRoles() {
return roles;
}
}
These examples make the contract explicit and protect the system from accidental mutation and null handling errors.
Troubleshooting: diagnosing UnsupportedOperationException
If you see an UnsupportedOperationException that points to AbstractList.add or remove, it usually means one of two things:
- You tried to mutate Collections.emptyList() or List.of().
- You received an unmodifiable list view and tried to change it.
The fix is to understand the ownership of the list. If you own it, create a mutable copy before modifying:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class FixMutationDemo {
public static void main(String[] args) {
List safe = Collections.emptyList();
List working = new ArrayList(safe);
working.add("ok");
System.out.println(working);
}
}
If you do not own it, change your code to avoid mutation or request a mutable list from the provider.
A note on documentation and API signals
I treat emptyList() as a documentation tool. When a method returns emptyList(), it signals “you can iterate safely; you cannot mutate.” If I want to be explicit, I mention that in JavaDoc or in method naming. That makes it easier for other developers to use the API correctly.
Example JavaDoc snippet:
/
* Returns an immutable list of tags. Never null.
*/
public List getTags() { ... }
This is a small addition, but it clarifies expectations and prevents misuse.
Comparison table: emptyList() vs alternatives
Here is a quick table that captures the most practical differences:
Mutable
Best use
—
—
No
Default/return empty list
No
Inline literals, modern code
Yes
Build-up lists
No (view)
Freeze existing listI choose based on intent and contract clarity. For empty defaults, emptyList() is the most explicit choice.
Closing thoughts and next steps
I keep Collections.emptyList() in my daily toolbox because it solves a real problem: it replaces null with an explicit, safe value that is also immutable. That single choice makes code easier to read, easier to test, and easier to trust. It is a simple technique, yet it prevents a class of bugs I see over and over in production systems. If you take one practical step after reading this, scan your service methods and replace null returns for lists with emptyList(). That is a low-risk change with high payoff.
If you are working on a public API, consider returning emptyList() when there are no results, and return an unmodifiable list when there are results. That keeps the contract consistent and avoids accidental mutation by callers. If you are building internal tooling or data pipelines, use emptyList() as your default to simplify logic and reduce the number of null checks. In 2026 workflows, I also recommend setting up static analysis rules that flag null list returns and suggest emptyList() instead, then let AI-assisted review help you audit edge cases.
As you move forward, keep the intent front and center: emptyList() is not just an empty container, it is a statement about your API. It says “no data, no surprises.” That is exactly the kind of contract I want in my code.


