Ch. 7 · Java

UnsupportedOperationException from Arrays.asList and List.of

Why Arrays.asList and List.of throw UnsupportedOperationException on add or remove, and how to choose a mutable copy or an immutable list.

~6 min readbeginnerupdated Oct 4, 2026

You called add, remove or clear on a list and got an exception with no message at all:

Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.AbstractList.add(AbstractList.java:155)
	at java.base/java.util.AbstractList.add(AbstractList.java:113)
	at Orders.main(Orders.java:7)
Text

If the list came from List.of, Stream.toList() or List.copyOf, the frames name the JDK’s immutable collections instead:

Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
	at Orders.main(Orders.java:7)
Text

UnsupportedOperationException means the object implements the List interface but has chosen not to support that particular operation. The List interface marks mutators such as add and remove as optional, and several standard factory methods return lists that refuse them. The frame names (AbstractList versus ImmutableCollections) tell you which kind of list you are holding; the line numbers inside them vary by JDK release.

Quick fix checklist

  • Find where the list was created: Arrays.asList, List.of, List.copyOf, Stream.toList(), Collections.unmodifiableList, Collections.emptyList() or a library method that returns one of these.
  • If the code that throws genuinely needs to modify the list, copy it first: new ArrayList<>(list).
  • If you own the method that created the list and callers are expected to modify it, return an ArrayList and document that.
  • If the list should never change, the exception found a real bug: fix the code that tried to modify it.
  • Watch for the related NullPointerException from List.of(null) or List.of(...).contains(null).

Before you start

You should know the difference between the List interface and its implementations. A variable typed List<String> tells you nothing about whether the object behind it is resizable, writable or neither. That is the root of the confusion: the compiler allows list.add(x) on every List, and only the runtime object decides. The examples assume Java 17 or later, where Stream.toList() exists (it was added in Java 16).

Why it happens

There are three families of “restricted” lists in the JDK, and they behave differently.

Fixed-size views: Arrays.asList. It wraps the array you pass in without copying it. Because an array cannot grow or shrink, the wrapper does not override add or remove, and the inherited AbstractList versions throw. It does support set, and writes go straight through to the array in both directions:

String[] codes = {"A", "B", "C"};
List<String> view = Arrays.asList(codes);

view.set(0, "Z");          // allowed: codes[0] is now "Z"
codes[1] = "Y";            // view.get(1) is now "Y"
view.add("D");             // UnsupportedOperationException
java

That is why Collections.sort(Arrays.asList(...)) works (sorting only uses set), while removing does not.

Unmodifiable lists: List.of, List.copyOf, Stream.toList(). These are documented as unmodifiable: every mutator throws, including set, sort, replaceAll and removeIf. List.of and List.copyOf also reject null elements with NullPointerException, and even contains(null) throws on them. Stream.toList() is the exception that allows null elements. Map.of follows the same rules for maps and additionally throws IllegalArgumentException for duplicate keys.

Unmodifiable views: Collections.unmodifiableList. This wraps an existing list and blocks writes through the wrapper, but it is not a copy. Anyone holding the original list can still change it, and the view shows the changes.

List<String> source = new ArrayList<>(List.of("a"));
List<String> readOnly = Collections.unmodifiableList(source);
source.add("b");
System.out.println(readOnly); // [a, b]
java

Step-by-step walkthrough

Step 1: Reproduce the failure

import java.util.Arrays;
import java.util.List;

public class Orders {
    public static void main(String[] args) {
        List<String> statuses = Arrays.asList("NEW", "PAID", "SHIPPED");
        statuses.set(0, "PENDING");   // fine
        statuses.add("DELIVERED");    // throws
    }
}
java

The trace shows AbstractList.add above line 8 of Orders. That AbstractList frame is the signature of a list that never implemented add.

Step 2: Identify the list’s real class

When the list comes from somewhere far away, ask it what it is:

System.out.println(list.getClass().getName());
java

java.util.Arrays$ArrayList is the fixed-size wrapper (a private class, not java.util.ArrayList). Names such as java.util.ImmutableCollections$ListN or ImmutableCollections$List12 mean List.of or a related factory. java.util.Collections$UnmodifiableRandomAccessList is the view from Collections.unmodifiableList. Now you know which rules apply.

Step 3: Decide who should own mutation

This is the real decision. Two healthy designs exist:

  • The producer returns an unmodifiable list, and any consumer that needs changes makes its own copy. This protects the producer’s internal state.
  • The producer returns a fresh mutable list that the caller owns outright.

Problems start when the producer returns an unmodifiable list and a consumer assumes it can modify it, or when the producer returns its internal mutable list and a consumer modifies it by accident.

Step 4: Apply the fix

If the consumer needs to change the data, copy at the point of change:

List<String> statuses = new ArrayList<>(Arrays.asList("NEW", "PAID", "SHIPPED"));
statuses.add("DELIVERED");    // fine: a real ArrayList
java

If you need a growable list from a stream, ask for one explicitly rather than relying on Collectors.toList(), whose mutability is not guaranteed by its specification:

List<String> codes = orders.stream()
    .map(Order::code)
    .collect(Collectors.toCollection(ArrayList::new));
java

If the list must be immutable, use List.copyOf at the boundary so later changes to the source cannot leak in.

Step 5: Make the contract visible

Document return values (“returns an unmodifiable list”), and prefer immutable return values for anything that exposes internal state. Name methods so mutation is obvious (addItem on the owning class, rather than getItems().add).

Worked scenario

A pricing service builds default discount rules and lets each request add its own:

class DiscountRules {
    static List<String> defaults() {
        return List.of("LOYALTY", "SEASONAL");
    }
}

class CheckoutService {
    List<String> rulesFor(Customer customer) {
        List<String> rules = DiscountRules.defaults();
        if (customer.isStudent()) {
            rules.add("STUDENT");
        }
        return rules;
    }
}
java

It worked until the first student checked out. The trace showed ImmutableCollections$AbstractImmutableCollection.add above CheckoutService.rulesFor.

Diagnosis: defaults() used to return new ArrayList<>(Arrays.asList(...)). A refactor changed it to the tidier List.of(...), which is unmodifiable. No test covered a student customer, so nothing failed at build time.

The original design was wrong in a quieter way too: if defaults() had returned a shared static ArrayList, the first student request would have added STUDENT to everyone’s defaults. Keeping defaults() immutable is the right choice. The consumer that wants to add rules makes its own copy:

class CheckoutService {
    List<String> rulesFor(Customer customer) {
        List<String> rules = new ArrayList<>(DiscountRules.defaults());
        if (customer.isStudent()) {
            rules.add("STUDENT");
        }
        return List.copyOf(rules);
    }
}
java

The method now returns its own immutable result, so callers further downstream cannot corrupt it either.

Common mistake

The tempting fix is to make the producer return a mutable list “so nothing breaks”. That removes the exception and the protection with it: callers can now edit shared state, and the bug moves from a loud exception to silent data corruption.

Other mistakes:

  • Wrapping with Collections.unmodifiableList and assuming the data can no longer change. It is a view; use List.copyOf for a real snapshot.
  • Using Arrays.asList on a primitive array. Arrays.asList(new int[] {1, 2}) returns a List<int[]> with one element, not a list of two integers. Use IntStream.of(...).boxed().toList() or List.of(1, 2).
  • Trusting a test that calls removeIf on an Arrays.asList list with a predicate that matches nothing. Nothing is removed, so nothing throws; production data that matches will throw. Lists from List.of throw from removeIf regardless.

Verify the behavior

Write tests that pin the contract of the producer and the behaviour of the consumer:

import static org.junit.jupiter.api.Assertions.*;

import java.util.List;
import org.junit.jupiter.api.Test;

class CheckoutServiceTest {
    @Test
    void defaultsCannotBeModified() {
        List<String> defaults = DiscountRules.defaults();
        assertThrows(UnsupportedOperationException.class, () -> defaults.add("HACK"));
    }

    @Test
    void studentGetsExtraRuleWithoutChangingDefaults() {
        Customer student = new Customer("Sam", true);
        List<String> rules = assertDoesNotThrow(() -> new CheckoutService().rulesFor(student));
        assertEquals(List.of("LOYALTY", "SEASONAL", "STUDENT"), rules);
        assertEquals(List.of("LOYALTY", "SEASONAL"), DiscountRules.defaults());
    }
}
java

The tests assume a record Customer(String name, boolean student) with an isStudent() method that returns student. The second test covers both the exception and the shared-state risk.

Interview exercise

What does this print, and why?

String[] arr = {"x", "y"};
List<String> a = Arrays.asList(arr);
List<String> b = List.of(arr);
arr[0] = "z";
System.out.println(a.get(0) + " " + b.get(0));
a.set(1, "w");
b.set(1, "w");
java

Answer and reasoning

It prints z x, then throws UnsupportedOperationException on the last line. Arrays.asList wraps the array without copying, so changing arr[0] is visible through a. List.of(arr) copies the elements into a new unmodifiable list, so b still sees x. The call a.set(1, "w") succeeds because the fixed-size view supports replacement (and writes "w" into arr[1]), but b.set(1, "w") throws because List.of lists support no mutators at all. A strong answer names the three behaviours separately (view with write-through, fixed size, full immutability) and mentions that List.of would also have thrown NullPointerException if the array had contained a null.

Continue learning

More in Java

esc