Skip to content
pbb.io/blog

Ecto.UUID.cast/1 will eat anything sixteen bytes long

A book slug returning 400 as an Ecto.Query.CastError, because a sixteen-byte string is also a valid raw UUID. Why casting is the wrong way to tell ids and slugs apart.

July 30, 2026
3 min read
Elixir Ecto

A Sentry issue landed this morning: /books/ten-little-bears returning 400, Ecto.Query.CastError. The record exists. The slug is right there in the database. Ecto is kind enough to print the offending query with the error, and it told the whole story before I opened a single file:

from b0 in Book,
where: b0.active == true,
where: b0.id == ^"ten-little-bears",
select: b0

b0.id. Not b0.slug. Something had decided this slug was a UUID.

The lookup function is about as innocent as code gets:

def get(scope, id_or_slug) when is_binary(id_or_slug) do
case Ecto.UUID.cast(id_or_slug) do
{:ok, _uuid} -> get_by(scope, :id, id_or_slug)
:error -> get_by(scope, :slug, id_or_slug)
end
end

Cast it. If it’s a UUID, look up by id, otherwise by slug.

The problem is that Ecto.UUID.cast/1 accepts two input forms. The canonical 36-character string, obviously. And any raw 16-byte binary, because that’s the packed representation a database actually stores. Both are legitimate UUID inputs, so both cast successfully.

"ten-little-bears" is exactly sixteen bytes.

iex> Ecto.UUID.cast("ten-little-bears")
{:ok, "74656e2d-6c69-7474-6c65-2d6265617273"}

Stare at that hex for a second, then run it backwards:

iex> "74656e2d-6c69-7474-6c65-2d6265617273"
...> |> String.replace("-", "")
...> |> Base.decode16!(case: :lower)
"ten-little-bears"

The “UUID” is the slug, hex-encoded. Ecto did precisely what it was asked to do.

There’s a second bug hiding behind the first, and it’s the one that turned a silent failure into a loud one: the code casts id_or_slug but then queries with id_or_slug, the original rather than the cast result. So the raw string went into the query, failed to dump to :binary_id, and raised. Had it used the cast result, we’d have quietly looked up 74656e2d-…, found nothing, and served a 404 for a page that exists. Louder was better here.

The fix is a strict predicate that only recognizes the canonical form:

def uuid?(<<_time_low::64, ?-, _time_mid::32, ?-, _time_hi::32, ?-, _clock_seq::32, ?-, _node::96>> = value),
do: match?({:ok, _uuid}, Ecto.UUID.cast(value))
def uuid?(_value), do: false

The binary pattern pins the hyphens and the total length; Ecto.UUID.cast still validates the hex. Raw binaries can’t get through.

Ecto.UUID.dump/1 would work as a predicate too, since today it only accepts the canonical form. But that’s inherited behavior, not a stated contract, and nothing stops a future Ecto from teaching dump/1 the raw form for symmetry with cast/1. The pattern spells out exactly what we accept.

Ecto isn’t broken. A cast answers “can this become a UUID?”, not “is this a UUID?”. Use it to tell types apart and you inherit every form it accepts.

Back to posts

Elixir Ecto
Phil-Bastian Berndt

Phil-Bastian Berndt
Tech Lead at Naymspace