airbnb/epoxy · critical · IllegalStateException
Two models have the same ID. ID's must be unique! Model at…
Error message
Two models have the same ID. ID's must be unique! Model at position {position}: {model} Model at position {previousPosition}: {previousModel} What it means
createStateForPosition builds a ModelState keyed by model id; if another model already occupies that id in currentStateMap, it throws IllegalStateException listing both positions and models. Epoxy requires globally unique model ids within a set of models because diffing and stable-id RecyclerView behavior key off them.
Solutions
- Assign a unique id to every model (e.g. .id(position) or a stable business key)
- Check for accidental duplicate add() of the same model instance
- If a model legitimately appears twice, give each occurrence a distinct id or use EpoxyModel with id including a sub-identifier
- Add a debug assert before setModels to detect duplicate ids early (EpoxyController.validateModelBuilding does this in controllers)
Example fix
// before
for (Item item : items) {
add(new ItemModel_().id(1).item(item)); // duplicate id
}
// after
for (Item item : items) {
add(new ItemModel_().id(item.getId()).item(item));
} Defensive patterns
Strategy: validation
Validate before calling
// assert unique ids before building models
Set<Long> seen = new HashSet<>();
for (EpoxyModel<?> m : models) {
if (!seen.add(m.id())) throw new IllegalStateException("Duplicate model id: " + m.id());
} Prevention
- Assign a unique id to every model, derived from data (not a constant)
- Enable Epoxy's model-validation in debug builds to catch duplicates early
- Never add the same model instance twice; build fresh models per item
When it happens
Trigger: Adding two models with the same id() to the adapter's model list — e.g. duplicate add(), a loop reusing one model instance/id, or forgetting to set a unique id on programmatically built models — then building state via prepareStateForDiff or observing an insert.
Common situations: Models created in a loop without setting distinct ids; accidentally adding the same model instance twice; two view types sharing a default/auto id; switching to diffing from an adapter that never enforced unique ids.
Understand the failure class
Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.
Related errors
- Diffing is enabled. You should use notifyModelsChanged…
- Moving more than 1 item at a time is not supported. Number…
- Unknown type
- Models must not be empty
- Diffing was already enabled
AI-assisted analysis of airbnb/epoxy@e45bd3a61f (2026-09-13).
Data as JSON: /api/errors/241eee67f20e4bcc.
Report an issue: GitHub.
Appendix: source
Thrown at epoxy-adapter/src/main/java/com/airbnb/epoxy/DiffHelper.java:241
int modelCount = adapter.getCurrentModels().size();
currentStateList.ensureCapacity(modelCount);
for (int i = 0; i < modelCount; i++) {
currentStateList.add(createStateForPosition(i));
}
}
private ModelState createStateForPosition(int position) {
EpoxyModel<?> model = adapter.getCurrentModels().get(position);
model.addedToAdapter = true;
ModelState state = ModelState.build(model, position, immutableModels);
ModelState previousValue = currentStateMap.put(state.id, state);
if (previousValue != null) {
int previousPosition = previousValue.position;
EpoxyModel<?> previousModel = adapter.getCurrentModels().get(previousPosition);
throw new IllegalStateException("Two models have the same ID. ID's must be unique!"
+ " Model at position " + position + ": " + model
+ " Model at position " + previousPosition + ": " + previousModel);
}
return state;
}
/**
* Find all removal operations and add them to the result list. The general strategy here is to
* walk through the {@link #oldStateList} and check for items that don't exist in the new list.
* Walking through it in order makes it easy to batch adjacent removals.
*/
private void collectRemovals(UpdateOpHelper helper) {
for (ModelState state : oldStateList) {
// Update the position of the item to take into account previous removals,
// so that future operations will reference the correct position
state.position -= helper.getNumRemovals();
View on GitHub (pinned to e45bd3a61f)