Validating Uniqueness in Ecto 2.2

elixir ecto
Posted on: 2017-08-18

As the changelog shows, Ecto 2.2 will support validating uniqueness using unsafe_validate_unique/3. This is kind of a big change for Ecto, and since I helped bring it about, I thought it might be worth explaining.

Here's a quick recap of how we got here.

The ActiveRecord Way

A lot of ORMs and querying libraries support validating uniqueness. Ruby's ActiveRecord is one that I'm familiar with.

class User < ApplicationRecord
  validates :username, uniqueness: true
end

Before inserting a user, this model would query for existing users with that address, bail if it finds any and proceed if it doesn't.

This is inherently unsafe, as I've written about before, because a race condition could lead to two users with the same username. Rubyists generally use a unique index in the database to give real assurance. They typically don't bother rescuing the error that results if it's violated (though I've written about a way to do that).

The Ecto Way

Recognizing that application-level validations can't prevent data conflicts, the authors of Ecto initially decided not to support them.

Instead, the strategy was:

  • Create a database constraint, such as a unique index, using migrations
  • If you want to show users a friendly message when the constraint is violated, use something like unique_constraint(changeset, :username). This annotates the constraint to say, "if the database complains about this constraint when we try to persist the changeset, set a friendly error message on it." (Note that this is optional; there's no point if the problem is one that a user can't fix.)

The Downside of Constraints

This works well. But it's not always a great user experience, because unlike validations, constraints are checked one-at-a-time. In other words, if your data violates 3 constraints, the database will only tell you about the first one it finds.

So in the worst case, the user experience might be:

  • Submit a form
  • Get validation errors like "name can't be blank"
  • Fix them and resubmit
  • Get a uniqueness constraint error like "username is taken". This is confusing, because the user thought she fixed all the issues.
  • Fix it and resubmit
  • Get an exclusion constraint error like "dates can't overlap"
  • Fix it and resubmit...

Not awesome.

The "Belt and Suspenders" Approach

With the addition of unsafe_validate_unique/3, you can now check uniqueness in the validation phase. (The third argument is the repo.)

There are some downsides to this.

  • Your validations need to run a query, making them slightly slower. But to the user, that's an extra few milliseconds, vs several annoyed seconds re-submitting the form.
  • For the first time, Ecto.Changeset depends on Ecto.Query (though not Ecto.Repo). I don't think that's a big deal, but if need be, unsafe validations could be extracted to a separate package.
  • Validating uniqueness is unreliable. But the reality is that if another user already claimed your username, 99.9% of the time they did it more than a few milliseconds ago. Like, maybe they got it a year ago.

So in the vast majority of cases, you'll be able to fix "username is taken" at the same time as "name can't be blank", without a separate form submission.

But it's unsafe, as the name indicates. You still need the database constraint, and you you should still use unique_constraint(changeset, :username) if you want users to get a friendly message in the rare case of a race condition.

Using them both may feel redundant, but if you want to give users the best experience you can and still guarantee that your data is correct, it's the way to go as of Ecto 2.2.

Clarification: if you check uniqueness as the user types via JS requests to the server, the user is already getting the fastest feedback possible, and you shouldn't need any other check except the uniqueness constraint.