Fixing `Ecto.StaleEntryError` with optimistic locking patterns

almirsarajcic

almirsarajcic

2 hours ago

Race conditions in concurrent updates cause cryptic Ecto.StaleEntryError crashes in production. The error happens when multiple processes try to update the same record simultaneously, leaving your users with broken workflows and your logs full of mysterious stacktraces.

# Add this single field to prevent 90% of concurrency issues
defmodule MyApp.User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :email, :string
    # The magic field
    field :lock_version, :integer, default: 0

    timestamps()
  end
end

Ecto’s optimistic locking automatically increments lock_version on every update and validates it matches the expected value. If another process updated the record first, you get a clear Ecto.StaleEntryError instead of silent data corruption.

You need to add optimistic_lock/3 to your changeset to enable this behavior:

def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email])
  # This enables the magic
  |> optimistic_lock(:lock_version)
end

Handle the error gracefully in your context functions:

def update_user(user, attrs) do
  user
  |> User.changeset(attrs)
  |> Repo.update()
rescue
  Ecto.StaleEntryError ->
    # Fetch fresh data and retry, or return meaningful error
    {:error, :concurrent_update}
end

For LiveView forms, reload the data when users encounter stale updates:

def handle_event("save", params, socket) do
  case Accounts.update_user(socket.assigns.user, params) do
    {:ok, user} ->
      {:noreply, assign(socket, :user, user)}

    {:error, :concurrent_update} ->
      # Refresh with latest data
      fresh_user = Accounts.get_user!(socket.assigns.user.id)

      {:noreply,
       socket
       |> assign(:user, fresh_user)
       |> put_flash(:error, "Someone else updated this record. Please try again.")}
  end
end

The migration is straightforward - just add the field with a sensible default:

def change do
  alter table(:users) do
    add :lock_version, :integer, default: 0, null: false
  end
end

This pattern works for any schema where concurrent updates matter: user profiles, settings, inventory, financial records. The lock_version field costs 4 bytes per row but saves hours of debugging race conditions in production.

This feature is quietly documented in Ecto but rarely mentioned in guides or tutorials. You can find the full details in Ecto.Changeset.optimistic_lock/3 - it’s been hiding in plain sight, preventing race conditions for years.