Ecto's uniqueness constraint vs Rails' uniqueness validation

I really like how closely Ecto integrates with our database. That was a surprising difference when I first came from Rails.

Ecto’s constraints are a great example of that. Like Rails, Ecto has validations. But unlike Rails, Ecto also has constraints. Validating uniqueness is the example that most easily comes to my mind.

Rails uniqueness validation

With Rails, we can validate uniqueness through the validate method (with a uniqueness option) or through the validates_uniqueness_of method. Both do the same:

validates that the attribute’s value is unique right before the object gets saved.

class Account < ApplicationRecord
  validates :email, uniqueness: true
end

# OR

class Account < ApplicationRecord
  validates_uniqueness_of :email
end

But as the Rails guides state:

It does not create a uniqueness constraint in the database, so it may happen that two different database connections create two records with the same value for a column that you intend to be unique.

Two spider-mans pointing at each other.
The multiverse failing uniqueness

Because the validation happens before records are inserted into the database, we always have the possibility of a race condition — for example, if one user quickly submits the same form twice or two users submit the form at the same time.

The Rails guides go on to suggest a solution:

To avoid that, you must create a unique index on that column in your database.

Unfortunately, Rails does nothing out of the box when the database’s unique index raises an error:

You can either choose to let this error propagate (which will result in the default Rails exception page being shown), or you can catch it and restart the transaction

So, even though Rails suggests using a unique index at the database level to ensure data integrity, we have to roll our own exception-catching and error-handling if we want a good experience for our users.

Ecto’s uniqueness constraint

Ecto also allows us to validate the uniqueness of an attribute through unique_constraint/3:

defmodule Account
  import Ecto.Changeset

  def changeset(account, params) do
    account
    |> cast(params, [:email])
    |> unique_constraint(:email)
  end
end

But unlike Rails’ uniqueness validation, Ecto’s unique_constraint/2 requires we create a unique index in our database:

to use the uniqueness constraint, the first step is to define the unique index in a migration

Then, Ecto integrates with it, rescuing the exception and turning it into a nice error for our users:

The unique constraint works by relying on the database to check if the unique constraint has been violated or not and, if so, Ecto converts it into a changeset error.

So, both Rails and Ecto recommend creating a unique index in our database for data integrity. But only Ecto takes an extra step and integrates with the database, turning the exception into a nice error message for our users – blending the strengths of our application with those of our database.

Ecto’s unsafe validations

Of course, the downside of relying on a database constraint is that we do not know if the record is unique until we try to insert it into the database. And there are times when we want to validate a record’s uniqueness (subject to race conditions) before we have to persist it. For those situations, Ecto has a function called unsafe_validate_unique/4.

By explicitly labeling the function as “unsafe”, Ecto suggests that we shouldn’t trust it for data integrity. The function can help as a first layer of errors for our customers, but ultimately, we should rely on the unique_constraint/3:

This function exists to provide quick feedback to users of your application. It should not be relied on for any data guarantee as it has race conditions and is inherently unsafe. For example, if this check happens twice in the same time interval (because the user submitted a form twice), both checks may pass and you may end-up with duplicate entries in the database. Therefore, a unique_constraint/3 should also be used to ensure your data won’t get corrupted

Thus, by integrating more closely with the database, Ecto offers the best of both worlds: data integrity as the recommended default, and an unsafe way to get quick feedback when needed.

Want my latest thoughts, posts, and projects in your inbox?

    I will never send you spam. Unsubscribe any time.