mongoose-unique-validator: a friendlier duplicate key error, and the race it cannot close
mongoose-unique-validator is a plugin which adds pre-save validation for unique fields within a Mongoose schema.
At a glance
- What is it?
- mongoose-unique-validator is a Mongoose plugin that checks unique fields before a save and reports a duplicate as a normal validation error rather than a raw MongoDB E11000. The README is unusually honest about the gap: it queries the database before saving, so a concurrent write can still slip past it and the unique index remains the real enforcement.
- Who is it for?
- Adopt mongoose-unique-validator if your API returns validation errors and you want duplicates to arrive in the same shape as every other validation failure, and you keep the unique index in place underneath. Do not adopt it as the mechanism enforcing uniqueness, because the plugin validates by querying before save and two concurrent saves can both read a count of zero and both insert, in which case MongoDB's unique index rejects the second with a raw E11000 anyway.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 88 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem is the shape of the error, not the constraint
mongoose-unique-validator is a plugin which adds pre-save validation for unique fields within a Mongoose schema.
The reason it exists is stated in one sentence. This makes error handling much easier, since you will get a Mongoose validation error when you attempt to violate a unique constraint, rather than an E11000 error from MongoDB.
That is the whole value proposition, and it is worth being clear that it is a user experience change rather than a correctness change. A duplicate key error from the database arrives at a different layer of the stack, with a different shape, and typically has to be caught separately in every controller or every error handler. A validation error arrives through the same path as a missing required field, so the client-facing error handling stays uniform.
The plugin is applied to a schema the same way any Mongoose plugin is:
import mongoose from 'mongoose'
import uniqueValidator from 'mongoose-unique-validator'
const userSchema = mongoose.Schema({
username: { type: String, required: true, unique: true },
email: { type: String, index: true, unique: true, required: true },
password: { type: String, required: true }
})
userSchema.plugin(uniqueValidator)Note that the fields still carry the ordinary Mongoose unique option. The plugin does not replace the index, and the README is explicit later on that the index must stay.
The project is MIT licensed, the primary language is TypeScript, the default branch is master, and the last push was on 2026-07-28. The release history is 6.1.1 from 2026-03-24, 6.1.0 from 2026-03-04 and 6.0.0 from 2026-02-19, and the package manifest requires Node 22 or newer.
The error you get back, and the shape it takes
When a duplicate is caught, the error that surfaces is a standard Mongoose ValidationError, and the documentation shows both the try and catch and the resulting object.
try {
const user = new User({
username: 'JohnSmith',
email: '[email protected]',
password: 'j0hnNYb0i'
})
await user.save()
} catch (err) {
// err.name === 'ValidationError'
}And the error object carries the field, the offending value and the kind of validator that failed:
{
message: 'Validation failed',
name: 'ValidationError',
errors: {
username: {
message: 'Error, expected `username` to be unique. Value: `JohnSmith`',
name: 'ValidatorError',
kind: 'unique',
path: 'username',
value: 'JohnSmith'
}
}
}The kind field is set to unique by default, and the path and value fields are what let a generic error handler produce a per-field message without knowing anything about your schema.
The package itself is small and the manifest shows the shape of it. The main entry is index.js with types from index.d.ts, the package is type module, and the test script is mocha. The dev dependencies include mongoose itself, chai for assertions, eslint and prettier for the checks, husky and lint-staged for the git hooks, and a prettier plugin for sorting imports.
There is also a docker-compose.yml for the test run, with a mongo service on the latest image, port 27017 published and a healthcheck that pings the admin command. The package scripts reflect that, with test:docker bringing the database up with --wait, running the tests and then taking it down again.
Updates are the case where this plugin needs help
There is a section on find and updates that exists because of how Mongoose works, and it is the part most likely to surprise you.
findOneAndUpdate and related methods do not run validation by default. You have to pass runValidators and context: 'query' to enable it:
User.findOneAndUpdate(
{ email: '[email protected]' },
{ email: '[email protected]' },
{ runValidators: true, context: 'query' },
function (err) {
// ...
}
)And the context option is required, not optional. Without it, this plugin cannot access the update values.
That second point is the one to remember. A query context tells Mongoose which part of the operation the validators are running against, and the plugin needs the new values to compare them against what is already in the collection. If you set runValidators and forget context, the validation either does not happen or happens against the wrong values, and either way you get a unique index error from MongoDB instead of the friendly message.
So the practical checklist for an existing codebase is short. Find every findOneAndUpdate and updateOne and updateMany that touches a unique field, and add both options. For most applications the update paths are a small fraction of the code, and they are exactly the paths where a duplicate will be attempted, because an update that sets a unique field to a value another document holds is the common case.
Customising the kind, the message and the code
Three separate options control how the error is reported, and each of them can be set per schema or globally.
The type option controls the kind field on the ValidatorError, and passing it changes what the error looks like:
userSchema.plugin(uniqueValidator, { type: 'mongoose-unique-validator' })The message option customises the message text, and three template variables are available. {PATH} is the field name, {VALUE} is the duplicate value, and {TYPE} is the error kind:
userSchema.plugin(uniqueValidator, {
message: 'Error, expected {PATH} to be unique.'
})The code option attaches a numeric or string code to each ValidatorError:
userSchema.plugin(uniqueValidator, { code: 11000 })and the code is available under properties.code, so a client can branch on a stable identifier rather than on the message text. That is the option most worth setting, because a message is something you will reword and a code is something your clients can rely on.
The global defaults section shows the alternative to passing options at every plugin call. Instead of passing options to every schema.plugin(uniqueValidator, ...) call, you set defaults once at startup, and per-schema options always take precedence:
import uniqueValidator from 'mongoose-unique-validator'
uniqueValidator.defaults.type = 'mongoose-unique-validator'
uniqueValidator.defaults.message = 'Error, expected {PATH} to be unique.'
uniqueValidator.defaults.code = 11000So the defaults are a starting point and the per-schema options are the override, which is the usual arrangement and the one that lets a project standardise on an error shape while a single schema deviates.
Case insensitivity and partial uniqueness, with a sharp edge
Two options change what counts as a duplicate, and the second one has a trap in it.
uniqueCaseInsensitive: true on a field treats values as case-insensitively equal, so [email protected] and [email protected] are considered duplicates. The example schema sets it alongside index and unique on the email field. The mechanism is a case-insensitive comparison against what the query returns, so it applies to the plugin's own check rather than to the index, and the two can disagree, which is worth keeping in mind if you also want the database to enforce the same rule.
Additional conditions use MongoDB's partialFilterExpression to scope the uniqueness constraint, and the example is uniqueness among non-deleted records:
const userSchema = mongoose.Schema({
username: { type: String, required: true, unique: true },
email: {
type: String,
required: true,
index: {
unique: true,
partialFilterExpression: { deleted: false }
}
},
password: { type: String, required: true }
})The note attached to it is the sharp edge. The index must be an object containing unique: true, and shorthand like index: true, unique: true will cause partialFilterExpression to be ignored.
That failure mode is nasty because it is silent. The index is still created and still enforces uniqueness across all documents, so the soft delete stops working, and nothing in the application says why. The index has to be written as an object from the start, and if you change it later you need to drop and recreate it, which is outside what this plugin controls.
The race condition, stated plainly
The Caveats section is the best part of the README and the most important thing to read before you adopt the plugin.
This plugin validates uniqueness by querying the database before save. Because two saves can run concurrently, both can read a count of zero and then both proceed to insert, resulting in a duplicate that MongoDB's unique index will reject with a raw E11000 error rather than a Mongoose ValidationError.
So the plugin is a UX layer, not a correctness guarantee. The unique index on the MongoDB collection remains the true enforcement mechanism and should always be kept in place. For most applications the race window is negligible, but in high-concurrency write scenarios the E11000 path is still reachable.
That framing is the whole story of the project, and it is good that it is in the README rather than discovered in production. The check is a read followed by a write, with no transaction and no lock between them, so the window is however long the query and the insert take. On a low-traffic endpoint that is microseconds and nobody will ever see it. On a high-traffic one with a contended value, it is the difference between a nice error and a stack trace.
The practical conclusion is that you need both. The plugin for the error shape on the common path, the index for the guarantee on the rare path, and an error handler that still knows about E11000 for when the race is lost. The README says the index should always be kept in place, and it is the only piece of the setup that is not optional.
Editorial conclusion
Adopt mongoose-unique-validator if your API returns validation errors and you want duplicates to arrive in the same shape as every other validation failure, and you keep the unique index in place underneath. Do not adopt it as the mechanism enforcing uniqueness, because the plugin validates by querying before save and two concurrent saves can both read a count of zero and both insert, in which case MongoDB's unique index rejects the second with a raw E11000 anyway. Before you rely on it, check that every findOneAndUpdate in your code passes runValidators and context: 'query', since without the context option the plugin cannot see the update values at all.
Frequently asked questions
What does mongoose-unique-validator do?
It is a Mongoose plugin that adds pre-save validation for unique fields within a schema, so a duplicate is reported as a Mongoose ValidationError rather than a raw E11000 error from MongoDB. It validates by querying the database before save.
Why does my findOneAndUpdate still throw a duplicate key error?
Those methods do not run validation by default. You need to pass { runValidators: true, context: 'query' }, and the context option is required because without it the plugin cannot access the update values.
Does mongoose-unique-validator guarantee uniqueness?
No. The README is explicit that it is a UX layer, not a correctness guarantee, because two concurrent saves can both read a count of zero and both insert. The unique index on the MongoDB collection remains the true enforcement mechanism and should always be kept in place.
How do I customise the error the mongoose-unique-validator produces?
Pass a type option to change the kind field, a message option with the {PATH}, {VALUE} and {TYPE} template variables, or a code option that is attached under properties.code. You can also set defaults once at startup on uniqueValidator.defaults, with per-schema options taking precedence.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/mongoose-unique-validator-mongoose-unique-validator)Was this useful?