Solving Ecto Upserts with Partial Unique Indices
- published
Recently I encountered a PostgreSQL error while trying to upsert (insert or update) records with soft deletes. When using partial unique indices, Ecto’s default upsert behavior doesn’t work out of the box, resulting in errors like the following. Here’s how I solved it.
** (Postgrex.Error) ERROR 42P10 (invalid_column_reference) there is no unique or
exclusion constraint matching the ON CONFLICT specification
Context
When implementing soft deletes (marking records as deleted instead of removing them), you often need unique constraints that only apply to active (non-deleted) records. This is where partial unique indices shine - they allow multiple “deleted” records with the same unique value while ensuring only one active record exists.
In this example, we want to ensure that each tag value exists only once among non-deleted records, but deleted records can have duplicate tag values.
Here’s the Ecto schema with soft delete support:
defmodule IdTag do
use Ecto.Schema
import Ecto.SoftDelete.Schema
import Ecto.Changeset
schema "idtags" do
field :description, :string
field :name, :string
field :tag, :string
soft_delete_schema() # Adds deleted_at field
end
def changeset(id_tag, attrs) do
id_tag
|> cast(attrs, [:name, :tag, :description])
|> validate_required([:name, :tag])
|> unique_constraint(:tag) # This won't work for partial indices
end
end
The migration creates a partial unique index that only considers non-deleted records:
defmodule Migrations.CreateIdtags do
use Ecto.Migration
import Ecto.SoftDelete.Migration
def change do
create table(:idtags) do
add :name, :string
add :tag, :string
add :description, :string
timestamps()
soft_delete_columns() # Adds deleted_at column
end
# Only enforce uniqueness for non-deleted records
create unique_index(:idtags, [:tag], where: "deleted_at IS NULL")
end
end
The Upsert Challenge
The problem arises when trying to use Ecto’s on_conflict with partial unique indices:
# This fails because Ecto doesn't know about the partial index predicate
%IdTag{}
|> IdTag.changeset(attrs)
|> Repo.insert(
on_conflict: [
set: [
name: attrs[:name],
description: attrs[:description]
]
],
conflict_target: [:tag] # Missing the WHERE clause!
)
With a little hint from
StackOverflow
the generated SQL shows the issue - no WHERE deleted_at IS NULL predicate:
INSERT INTO "idtags" AS i0 ("tag", ...) VALUES (...)
ON CONFLICT ("tag") DO UPDATE SET "name" = $8, "description" = $9 RETURNING "id" [...]
The Solution: Using unsafe_fragment
The key insight from the
Elixir forum discussion
is to use Ecto’s {:unsafe_fragment, ...} to include the index predicate directly in the conflict target:
%IdTag{}
|> IdTag.changeset(attrs)
|> Repo.insert(
on_conflict: [
set: [
name: attrs[:name],
description: attrs[:description]
]
],
conflict_target: {:unsafe_fragment, ~s<("tag") WHERE deleted_at IS NULL>}
)
This generates the correct SQL that matches our partial index:
INSERT INTO "idtags" AS i0 ("tag", ...) VALUES (...)
ON CONFLICT ("tag") WHERE deleted_at IS NULL
DO UPDATE SET "name" = $8, "description" = $9
RETURNING "id" [...]
Note: While unsafe_fragment solves this specific case, use it with caution as it bypasses Ecto’s SQL generation safety measures.
When to Use This Pattern
This approach is particularly useful when you need to:
- Maintain unique constraints across soft-deleted records
- Allow duplicate values for deleted records
- Use Ecto’s upsert functionality with complex index conditions
Alternative Approaches
If you frequently work with partial indices, consider:
- Creating a custom upsert function that handles your specific partial index predicates
- Using raw SQL for complex conflict resolution scenarios
- Implementing application-level conflict resolution logic
Hope you found this helpful!
If you need help with your Elixir/Phoenix project, feel free to contact me .