Home › Database › MongoDB E11000 Duplicate Key — Upsert or Drop Index
Beginner 5 min · September 23, 2026

MongoDB E11000 Duplicate Key — Upsert or Drop Index

Fix MongoDB E11000 by reading keyPattern/keyValue, upserting on the unique key, or dropping the rogue index.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
✓ Production
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 11 min
  • ✓A MongoDB database (4.4+) with mongosh access
  • ✓The failing write plus the collection's getIndexes output
  • ✓Comfort running aggregation pipelines for censuses
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • E11000 means a unique index rejected your write: the keyPattern names the index and keyValue names the colliding value — read both before touching code
  • List indexes with db.users.getIndexes(): the offender is often a leftover (rogue) index from an abandoned feature, not the constraint you intended
  • Choose upsert for sync and retry paths (updateOne with upsert:true merges on conflict) and keep plain inserts where duplicates should stay loud
  • Find strays with an aggregation grouping by the key (count > 1), then adopt, merge, or delete them before re-enabling strict writes
✦ Definition~90s read
What is MongoDB E11000 Duplicate Key Fix?

E11000 is raised when an insert or update would duplicate a key in a unique index — including the automatic _id index. The message carries three facts: the namespace (collection), the index (keyPattern like { email: 1 }, where 1/-1 show direction and "text"/"2dsphere" special types), and the offending keyValue document.

★
Picture assigned parking: every resident gets one numbered spot, and E11000 is the barrier refusing a second car with the same number.

Compound uniques reject only full-tuple collisions; single-field uniques reject per value; sparse uniques skip documents missing the field; partial uniques (partialFilterExpression) enforce only on matching documents.

Five stories share the code. Genuine duplicates: two real entities, one value — a product decision, not an engineering bug. Replays: the first write committed but its ack was lost (timeout, failover, retry), so the retry re-inserts. Races: concurrent find-then-insert pairs both seeing absence.

Dirty history: legacy rows predating the index, now blocking its creation or backfills. Rogue indexes: unique constraints from abandoned features (that beta invite code) still armed on the write path, rejecting values that are legitimately reusable.

The response maps to the story: upserts (updateOne with upsert:true on the unique key) make replays and races converge instead of erroring; plain inserts stay where duplicates must page; dropIndex retires rogues after proving no code depends on them; and aggregation censuses (group by key, match count > 1) size dirty-history cleanups before index builds. Unique enforcement plus idempotent write patterns is the whole doctrine.

Plain-English First

Picture assigned parking: every resident gets one numbered spot, and E11000 is the barrier refusing a second car with the same number. Maybe two drivers share a permit (a race), maybe the gate re-scanned your car after a glitch (a retry), or maybe the spot numbering belongs to last year's layout nobody removed (a rogue index). The responses match: register one car per spot (upsert), re-scan without fear (idempotent writes), or repaint the obsolete numbers off the asphalt (dropIndex).

E11000 duplicate key error collection: shop.users index: email_1 dup key: { email: "a@x.com" }. It's MongoDB's most informative error — collection, index, and colliding value in one line — yet it still burns hours, because the fix depends on the story: genuine double-submit, retried write, racing requests, dirty legacy data, or an index that shouldn't exist at all.

Unique indexes are the only cross-request duplicate protection MongoDB offers. Application checks ('find then insert') race under concurrency exactly like their SQL cousins, and retries after timeouts re-insert rows whose first attempt actually succeeded. The index is the arbiter doing its job; everything around it decides whether the rejection is a bug or a bodyguard.

This guide reads the error precisely, audits indexes for rogues, finds stray duplicates with aggregations, converts retry paths to upserts, drops obsolete indexes safely, and gates future indexes through review so E11000 stays a signal instead of noise forever.

Read keyPattern and keyValue

The message is a complete diagnosis in one line: collection (shop.users), index (email_1 — field plus direction), and colliding value ({ email: "a@x.com" }). keyPattern { email: 1 } describes the index shape (1 ascending, -1 descending, "text"/"2dsphere"/"hashed" for special types); keyValue is the exact document that collided. Copy both into the ticket — the next engineer reproduces with one find instead of re-deriving from logs.

Confirm against getIndexes before acting: options change everything. A sparse index skips null-missing documents (many nulls coexist); a non-sparse unique allows exactly one null (the beta-incident shape); a partial index enforces only documents matching partialFilterExpression. The same keyValue collides or coexists depending on options you can only see in the index definition.

Trend the pair over time: E11000s scattered across many keyValues mean dirty history or broad races; a single repeated keyValue means one stuck retry loop hammering the same document. The distribution picks the runbook — census-and-clean versus fix-the-loop — before you open either. Single-value floods also implicate client retry loops worth checking in the app logs alongside the database.

read_e11000.shBASH
1
2
3
4
5
6
7
8
9
10
11
# Full index definitions (options decide null/collision behavior)
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet \
  --eval 'db.users.getIndexes()' 

# The exact colliding document
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet \
  --eval 'db.users.findOne({email: "a@x.com"})'

# Index build/existence for the named index
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet \
  --eval 'db.users.getIndexKeys()'
📊 Production Insight
A team debugged a phantom double-write for 40 minutes while keyValue { invite_code: null } named the beta leftover from line one. Read the value first.
🎯 Key Takeaway
Collection, index, value in one line — confirm options via getIndexes, then let the keyValue distribution pick the runbook.

Find the Duplicates With Aggregation

The group-by census is the definitive collision map: group documents by the key (or full tuple for compound indexes), count, keep member ids, filter count > 1, sort by severity. It sizes the response (one pair = surgical merge, thousands = systemic dirt or a rogue) and produces the repair manifest (the ids list) in the same pass. Run it against the exact keyPattern fields — grouping by email when the index is {email, tenant} understates compound collisions.

Classify by recency like any orphan census: tight recent clusters mean races or replay storms (fix the write path); wide historical scatter means legacy dirt (clean the data); uniform null-key groups mean a non-sparse index meeting optional fields (fix the index definition). Each class has a different owner, and the timestamps route the ticket correctly on the first pass.

Promote the census to a pre-index-build gate: every createIndex on a populated collection runs the group-by first and fails the migration on any group. Index builds that discover dirt halfway abort expensively on huge collections — the census costs one aggregation while the aborted build wastes hours of I/O and blocks the entire deploy train behind it for hours.

census_dupes.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
# Collision census: groups, counts, member ids (compound: group full tuple)
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval '
db.users.aggregate([
  {$group: {_id: "$email", n: {$sum: 1}, ids: {$push: "$_id"}}},
  {$match: {n: {$gt: 1}}},
  {$sort: {n: -1}},
  {$limit: 20}
])' 

# Null-key groups (non-sparse unique vs optional fields)
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval '
db.users.countDocuments({invite_code: null})'
📊 Production Insight
One aggregation showed 41,000 null invite_codes under a non-sparse unique — the rogue's blast radius, quantified before the dropIndex decision.
🎯 Key Takeaway
Group by the exact key fields, count to size, timestamp to classify — and gate every index build on the census.

Upsert Instead of Insert

updateOne with upsert:true is the idempotent write primitive: filter on the unique key, $set the refreshable fields, $setOnInsert the first-write-wins fields (createdAt, initial source), and concurrent attempts converge — one inserts, the rest update. Retries after timeouts become harmless replays; double-clicks merge; races serialize on the index instead of erroring. The unique key graduates from guardrail to concurrency control.

Scope the operators deliberately. $set overwrites on every replay (right for status, wrong for createdAt); $setOnInsert writes only on insert (right for provenance, invisible on replays); $inc accumulates across replays (right for counters, disastrous for amounts — never $inc a payment on a retried webhook). Review each field's replay semantics or the upsert converts duplicate errors into duplicate charges.

Keep plain inserts where duplicates must stay loud: first-time registrations that genuinely collide deserve an error surfacing 'account exists', not a silent merge into someone else's row. Upsert the sync/retry paths; insert (and handle E11000 as business logic) on the human-facing create paths. Loud where humans decide, convergent where machines retry.

upsert_pattern.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
# Idempotent write: filter on unique key, split replay semantics
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval '
db.users.updateOne(
  {email: "a@x.com"},
  {$set: {name: "Ann Lee", updatedAt: new Date()},
   $setOnInsert: {createdAt: new Date(), source: "web"}},
  {upsert: true}
)' 

# Replay it: second run updates, never duplicates
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval '
db.users.countDocuments({email: "a@x.com"})' 
# expect: 1
📊 Production Insight
Converting webhook consumers to upsert:true cleared a 9,000-job retry backlog in minutes — replays merged, exactly one row each, zero double-charges.
🎯 Key Takeaway
upsert:true with $set/$setOnInsert split makes retries converge — and keeps plain inserts where collisions must page.

Rogue Indexes: Drop What Nobody Owns

A rogue index is a unique constraint whose feature died but whose enforcement lives on — beta invite codes, retired coupon formats, migrated-away external ids. Symptoms: E11000 on fields current code never sets (null collisions especially), index names referencing dead features, zero hits in code search for the field. getIndexes plus $indexStats (accesses since restart) confirms abandonment quantitatively: zero ops, still armed.

Drop deliberately, not eagerly. Prove no code path depends on the index (search + staged removal: hide the index first with hideIndex() on 4.4+, watch a full cycle, then dropIndex). Archive affected documents before cleanup so merges restore from backup collections, not oplog archaeology. Announce the drop like a schema migration — because it is one, with query-plan consequences for any reader that used the index.

Re-add correctly when the feature returns in new form: partial unique indexes (partialFilterExpression: { invite_code: { $exists: true } }) enforce only documents carrying the field, letting nulls coexist freely. The beta's blanket unique becomes the referral program's surgical one — same protection where it matters, silence where it doesn't. Record the decision and its evidence in the ticket for the next audit.

rogue_index.shBASH
1
2
3
4
5
6
7
8
9
10
11
# Usage evidence: zero ops since restart = abandonment candidate
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval '
db.users.aggregate([{$indexStats: {}},
  {$project: {name: 1, accesses: 1}}])' 

# Stage removal: hide first (4.4+), watch a cycle, then drop
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet \
  --eval 'db.users.hideIndex("invite_code_1")' 
# ... one clean cycle later ...
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet \
  --eval 'db.users.dropIndex("invite_code_1")'
⚠ Hide Before You Drop
dropIndex is instant but recreating on a huge collection takes hours. Hide the index first (4.4+), watch a full traffic cycle, then drop — the hide is reversible, the dropped index rebuild is not.
📊 Production Insight
Hiding the beta index for one cycle proved zero query-plan dependence — the drop then took a second, and signups recovered in the same minute.
🎯 Key Takeaway
Prove abandonment with $indexStats, hide for a cycle, then drop — and re-add as partial unique if the feature returns.

Races and Retries: Idempotent Writes

Find-then-insert races exactly like SQL check-then-insert: two requests both see absence, both insert, the loser's E11000 is the index saving you from a duplicate row. The error rate tracks traffic overlap, which is why the path passes every staging test and fails at launch — staging never runs the two requests in the same millisecond. Retries add the second head: timeouts lose acks, failovers replay, providers redeliver, and each replay re-inserts the committed row.

The single remedy covers both: filter-on-unique-key upserts as the only write shape on hot paths, with idempotency keys (request id, provider reference) as the unique field where natural keys don't exist. Every consumer gets a doubled-delivery test — same payload twice, assert one document — because providers will redeliver eventually and the test makes it a non-event.

Route repeated violations to dead-letter review with an alert, never back into the retry loop. An E11000 on a path you believe idempotent means the filter misses the real key (upserting on _id while uniqueness lives on email) or two business events share a token — both need human eyes promptly, not a 31st blind attempt against the same colliding key value.

replay_test_mongo.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash
# Doubled-delivery test: same payload twice, exactly one document
set -euo pipefail
CONN='mongodb://app:secret@db-staging:27017/shop'
for i in 1 2; do
mongosh "$CONN" --quiet --eval '
db.payments.updateOne({ref: "PAY-881"},
  {$set: {status: "paid"}, $setOnInsert: {createdAt: new Date()}},
  {upsert: true})' > /dev/null
done
mongosh "$CONN" --quiet --eval 'db.payments.countDocuments({ref: "PAY-881"})'
# expect: 1
📊 Production Insight
A doubled-delivery test in CI caught a non-idempotent refactor three weeks before the provider's next redelivery wave — the test paid for itself immediately.
🎯 Key Takeaway
Upsert-on-unique-key everywhere machines retry; doubled-delivery tests in CI; dead-letter repeated violations.

Prevention: Index Reviews and Partial Uniques

Govern indexes like schema: every createIndex or dropIndex rides the same review as migrations, with the census query attached for populated collections. Feature-sunset checklists include 'drop or convert its unique indexes' beside 'remove the flag and the code' — the beta incident was a checklist gap, not a technical surprise. Quarterly getIndexes audits (name, key, options, $indexStats accesses) catch drift that reviews miss.

Prefer partial uniques for optional fields: partialFilterExpression { field: { $exists: true } } enforces uniqueness only where values exist, so absent fields never collide. Sparse indexes cover legacy null-missing shapes; partial covers arbitrary predicates and reads clearer. Either beats the blanket unique that permits exactly one null and pages the second signup.

Validate builds on staging restores before production: build time, lock behavior, and dirt discovery all rehearse safely on data shaped like prod. Huge collections earn rolling builds via the documented procedure — an index build that stalls primaries is an availability incident wearing a DDL costume. Always rehearse every big build on a staging restore holding production-shaped data first, without exception.

partial_unique.shBASH
1
2
3
4
5
6
7
8
9
# Surgical unique: enforced only where the field exists
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval '
db.users.createIndex({invite_code: 1}, {unique: true,
  partialFilterExpression: {invite_code: {$exists: true}}})' 

# Quarterly audit: every index with options + usage
mongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval '
db.users.aggregate([{$indexStats: {}},
  {$project: {name: 1, key: 1, accesses: 1}}])'
💡Prefer Partial Over Blanket Uniques
partialFilterExpression enforces uniqueness only on documents carrying the field — nulls and missing values coexist freely. Blanket uniques on optional fields are E11000 factories.
📊 Production Insight
The beta's blanket unique returned as a partial unique for referrals — same protection on real codes, zero collisions on absent ones, no errors since.
🎯 Key Takeaway
Review indexes like migrations, sunset them with features, and prefer partial uniques for optional fields.
● Production incidentPOST-MORTEMseverity: high

Double-Submit Signups Hit a Rogue Beta Index for 90 Minutes

Symptom
At 10:00 AM launch, signup errors spiked to 20 per minute — E11000 on index invite_code_1 with dup key { invite_code: null }. The email unique index was clean; the failing index belonged to a closed-beta invite system. Mobile clients retried automatically, tripling volume. By 11:30 AM 1,800 signups had failed, support queues overflowed, and the launch hashtag filled with screenshots of the error screen instead of the product.
Assumption
The team assumed the signup service was double-writing and throttled the API to half rate — punishing legitimate users while the index kept rejecting. Forty minutes went to tracing a duplicate-write bug that never existed; every write was single, the armed index was the duplicate-maker (every NULL invite_code colliding under a non-sparse unique).
Root cause
A unique index on invite_code (non-sparse, no partial filter) survived the beta shutdown. Post-beta signups omit invite_code, storing null — and a non-sparse unique permits only ONE null. The first null-signup succeeded; every later one hit E11000 on { invite_code: null }. Double-clicks and retries amplified a deterministic rejection into a flood. getIndexes would have shown the rogue in seconds; nobody audited indexes after the beta.
Fix
At 11:30 AM they dropped the rogue (db.users.dropIndex('invite_code_1')) after confirming zero code references — signups recovered instantly. The 1,800 failed attempts were replayed from the request log with upserts. Follow-ups: partial unique (partialFilterExpression on present codes) re-added for the referral program, index review added to feature-sunset checklists, and double-submit protection (client disabling plus idempotency keys) shipped the same week.
Key lesson
  • Sunset the indexes with the feature: a uniqueness constraint outliving its feature becomes a deterministic rejector of legitimate writes — audit getIndexes at every feature shutdown.
  • Read keyValue, not just the code: { invite_code: null } named the beta leftover instantly, while the team debugged a phantom double-write for 40 minutes.
  • Make retries converge: upserts on the real unique key turn double-clicks and timeout replays into no-ops instead of error floods.
Production debug guideSix checks from keyValue to the story — genuine, replay, race, dirt, or rogue.6 entries
Symptom · 01
Writes fail with E11000 naming collection, index, and dup key
→
Fix
Read all three facts, then audit the index: mongosh --eval 'db.users.getIndexes()' — confirm the keyPattern, uniqueness, and options (sparse? partialFilterExpression?). A unique index on a field your current feature doesn't use (invite_code post-beta) is a rogue until proven otherwise — search the codebase for the field before deciding.
Symptom · 02
You need every document colliding on the key
→
Fix
Census with an aggregation: mongosh --eval 'db.users.aggregate([{$group:{_id:"$email",n:{$sum:1},ids:{$push:"$_id"}}},{$match:{n:{$gt:1}}},{$sort:{n:-1}},{$limit:20}])' — the groups size the cleanup (one pair = surgical, thousands = systemic) and the ids list is the repair manifest. For compound keys, group on the full tuple.
Symptom · 03
Collisions burst with deploys, failovers, or timeouts
→
Fix
Correlate: E11000 spikes aligned with elevated operationTime-outs or election events mean replays — first writes committed, acks lost, retries re-inserted. Sample the ops: mongosh --eval 'db.currentOp({"active":true})' during the burst. Fix the write pattern (upsert on the unique key), not the data — the rows are correct, the path isn't idempotent.
Symptom · 04
Two concurrent requests both passed an existence check
→
Fix
That's find-then-insert racing — the MongoDB twin of check-then-insert. Replace with updateOne({email}, {$setOnInsert: {...}, $set: {...}}, {upsert:true}): concurrent attempts serialize on the unique index, one inserts, the other updates. $setOnInsert keeps first-write-wins fields (createdAt) while $set refreshes the rest.
Symptom · 05
Creating the unique index itself fails with E11000
→
Fix
Dirty history: pre-existing duplicates block the build. Census with the group-by aggregation, fate each group (merge into keeper, delete strays after archiving to a backup collection), re-run the census to zero, then createIndex. Build with {background:true} semantics (default in modern versions) and validate on a staging restore first for huge collections.
Symptom · 06
Recovered — now prove the path is idempotent
→
Fix
Replay-test in staging: run the same insert twice via mongosh and assert one document (db.users.countDocuments({email:'probe@x.com'}) === 1). Add the double-delivery test to CI for every consumer, and route repeated key violations to dead-letter review — a 11000-series error on an 'idempotent' path means the merge logic is wrong.
MongoDB E11000 Causes at a Glance
Root CauseHow to ConfirmFixPrevention
Genuine double-submit / collisionSame value, human-speed timingSurface 'exists' or merge deliberatelyClient double-submit guards + idempotency keys
Timeout replay / redeliveryBursts with timeouts/failovers; identical docsUpsert on the unique keyDoubled-delivery CI per consumer
Find-then-insert raceClean in staging, fails at overlapupdateOne upsert:true; index arbitratesNever find-then-insert on hot paths
Dirty history blocking buildsCensus groups predate the indexFate groups, census to zero, then buildCensus gate before every createIndex
Rogue index from dead featureField unused; null collisions; zero $indexStatsHide a cycle, then dropIndexSunset indexes with features; quarterly audits
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
read_e11000.shmongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet \Read keyPattern and keyValue
census_dupes.shmongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval 'Find the Duplicates With Aggregation
upsert_pattern.shmongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval 'Upsert Instead of Insert
rogue_index.shmongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval 'Rogue Indexes
replay_test_mongo.shset -euo pipefailRaces and Retries
partial_unique.shmongosh 'mongodb://app:secret@db-primary:27017/shop' --quiet --eval 'Prevention

Key takeaways

1
E11000 names collection, index, and value
read keyPattern plus keyValue first.
2
Census collisions with group-by aggregation
count to size, timestamps to classify.
3
upsert:true with $set/$setOnInsert makes machine retries converge safely.
4
Rogues die by hide-then-drop after $indexStats proves abandonment.
5
Partial uniques enforce where fields exist; nulls coexist freely.
6
Review indexes like migrations and sunset them with their features.

Common mistakes to avoid

5 patterns
×

Throttling traffic for an index-driven rejection

Symptom
Legitimate users punished; deterministic E11000s continue at any rate.
Fix
Read keyValue — rogues reject at any volume; fix the index or the path, not the rate.
×

Retrying E11000s like transient errors

Symptom
Retry storms amplify deterministic rejections into pool exhaustion.
Fix
Upsert so replays converge; dead-letter repeated violations for human review.
×

$inc-ing amounts inside retried upserts

Symptom
Duplicates fixed; double-charges appear — replays accumulate money.
Fix
$set absolute values on replays; reserve $inc for true counters with dedupe.
×

Dropping the unique index to stop the errors

Symptom
E11000s end; silent duplicates corrupt analytics for months.
Fix
Keep the arbiter — upsert the path, census the dirt, drop only proven rogues.
×

Blanket unique on optional fields

Symptom
Second null fails — one missing value per collection, forever.
Fix
Sparse or partial uniques; enforce presence only where values exist.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does MongoDB E11000 tell you?
Q02SENIOR
How do you make a retried MongoDB write idempotent?
Q03SENIOR
Creating a unique index fails with E11000. Walk through it.
Q04SENIOR
When do you dropIndex versus upsert around E11000s?
Q05SENIOR
Design a signup path that survives double-clicks and retries.
Q01 of 05JUNIOR

What does MongoDB E11000 tell you?

ANSWER
A unique index rejected the write. The message names collection, index (keyPattern), and colliding value (keyValue) — diagnosis starts by reading all three, then checking index options.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Should I catch E11000 and retry the insert?
02
Can two nulls coexist under a unique index?
03
How do compound unique indexes collide?
04
Is hideIndex safe in production?
05
Why not $inc inside retried upserts?
06
How long does dropIndex take on huge collections?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
✓ Verified
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
🔥

That's MongoDB. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
ORA-01555 Snapshot Too Old Fix
1 / 1 · MongoDB
Next
Redis MISCONF RDB Snapshot Fix
→