Ecto, on_replace and deferred checks

by Paweł Świątkowski
03 Jul 2025

Today I learned a valuable lesson about how a seemingly simple task can have very rough edge cases, which take hours to solve. It involved Ecto, its associations and on_replace option, and uniqueness checks in the database. Here’s the story.

The problem

Let’s say you are modelling some kind of processes. These processes have steps and the steps have to be executed in a precise order. This is how a database structure for it would look like:

def change do
  create table(:processes) do
    add :name, :string, null: false
  end

  create table(:steps) do
    add :process_id, references(:processes)
    add :name, :string, null: false
    add :order, :integer, null: false
  end
end

It is fairly straightforward: the order of steps inside of a process is controlled by an order integer column. Since it is important to always have the order of steps precise, we would like to additionally ensure it by a uniqie index:

create unique_index(:steps, [:process_id, :order])

We also know that the steps are only edited via a parent process. There is a form in the application, where you can edit the process fields and add, change, delete steps for it. The payload sent to a server always includes all the steps. Armed with that knowledge we create Ecto schemas like this:

defmodule Core.Process do
  use Ecto.Schema
  import Ecto.Changeset

  schema "processes" do
    field :name, :string
    has_many :steps, Core.Step, on_replace: :delete, preload_order: [asc: :order]
  end

  def changeset(process, params) do
    process
    |> cast(params, [:name])
    |> cast_assoc(:steps)
  end
end

defmodule Core.Step do
  use Ecto.Schema
  import Ecto.Changeset

  schema "steps" do
    field :name, :string
    field :order, :integer
    belongs_to :process, Core.Process
  end

  def changeset(step, params) do
    step
    |> cast(params, [:name, :order])
  end
end

I did that, quite happily, tested a bit with adding, changing and removing some steps. Finally I merged the pull request.

It was only a few hours later when a colleague slacked me that something goes wrong. What he did was reordering the steps, or rather trying to do it. From this structure:

step 1, order: 1
step 2, order: 2

he wanted to go to

step 1, order: 2
step 2, order: 1

I quickly drafted code that replicated the issue:

proc =
  %Process{}
  |> Process.changeset(%{
    name: "Test",
    steps: [%{order: 1, name: "First"}, %{order: 2, name: "Second"}]
  })
  |> Repo.insert!()

proc = Repo.preload(proc, [:steps])
[s1, s2] = proc.steps

s1 = %{id: s1.id, name: s1.name, order: 2}
s2 = %{id: s2.id, name: s2.name, order: 1}

proc
|> Process.changeset(%{steps: [s1, s2]})
|> Repo.update!()

Attempt to run it resulted in an error:

** (Ecto.ConstraintError) constraint error when attempting to update struct:

    * "steps_process_id_order_index" (unique_constraint)

It took me a while to realize what’s going on: Ecto tried to update the existing steps, one by one. So the first operation was:

Update “order” of step 1 to 2

This left us with the following situation:

step 1, order: 2
step 2, order: 2

This, quite obviously in retrospect, triggered the unique index. You cannot have 2 steps for one process with the same order! It did not matter that right after you were going to update step 2’s order to 1. The index operates here and now, you cannot do that.

The solution

After discovering I was pretty close to writing my own hacky solution to update the steps, including inefficient approach with first deleting all and then creating all anew. Fortunately, in time, I vaguely remembered that there is something called deferrable constraints in PostgreSQL.

Deferrable constraint waits until the end of the transaction with checking if its condition is met. This was exactly what I was looking for! But an index cannot be deferrable in PostgreSQL. Luckily, another very similar construct - a uniqueness check - can.

In the migration above, I had to replace the index creation with this:

execute """
ALTER TABLE steps ADD CONSTRAINT unique_step UNIQUE (process_id, "order") DEFERRABLE INITIALLY DEFERRED
"""

With this statement we create a constraint on the steps table which checks uniqueness of two columns and not only is deferrable - it is deferred to be checked on transaction commit by default.

This is really all I had to do. The Ecto’s association replacing mechanism started working perfectly.

This proved once again a truth I knew: the database is not a dumb storage you can just gloss over. It always pays off to learn it, its capabilities and intricacies. Because then you end up with a simple change instead of rewriting part of Ecto, but poorly.

end of the article

Tags: elixir ecto database postgresql

This article was written by me – Paweł Świątkowski – on 03 Jul 2025. I'm on Fediverse (Ruby-flavoured account, Elixir-flavoured account). Let's talk.