We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Fixing `Ecto.StaleEntryError` with optimistic locking patterns
almirsarajcic
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.
copied to clipboard