Phoenix LiveView: UUID vs Integer ID Type Mismatch Error
When migrating a {{Phoenix LiveView}} app from integer to {{UUID}} primary keys, leftover `String.to_integer/1` calls on ID params crash with `ArgumentError` because {{Phoenix}} params arrive as strings and {{Ecto}} accepts UUID strings natively.
When switching a Phoenix LiveView application from integer primary keys to UUID (or UUIDv7) primary keys, a common runtime crash surfaces the first time a handler receives an ID from the URL or a form. ## The error The stack trace typically looks like: ``` ** (ArgumentError) errors were found at the given arguments: * 1st argument: not a textual representation of an integer :erlang.binary_to_integer("019d572b-7978-75fa-a624-7b3b2026e283") (elixir 1.18.x) lib/string.ex:_: String.to_integer/1 (my_app 0.1.0) lib/my_app_web/live/foo_live.ex:_: MyAppWeb.FooLive.handle_event/3 ``` The inner BIF `:erlang.binary_to_integer/1` raises `ArgumentError` (not `FunctionClauseError`) because the input is a hex-with-dashes string, not a decimal integer. `String.to_integer/1` is just a thin wrapper over that BIF, so the same crash appears whichever you call. ## Why this happens In Phoenix, all params — whether from path segments, query strings, form posts, or LiveView `phx-value-*` attributes — arrive as **string keys with string values**. Keys are deliberately kept as strings (not atoms) to prevent an attacker from exhausting the atom table by sending arbitrary param names. This is documented behavior in Phoenix.LiveView for `handle_params/3` and `handle_event/3`. With integer primary keys, the canonical recipe was `id |> String.to_integer() |> Repo.get!(...)`. That `String.to_integer/1` was always a coercion, never a validation — and it blows up the moment the same code receives `"019d572b-7978-75fa-a624-7b3b2026e283"` after a UUID migration. ## The fix Delete the `String.to_integer/1` (or `binary_to_integer/1`) call. Ecto treats `:binary_id` and `Ecto.UUID` fields as strings end-to-end: `Repo.get/2`, `Repo.get_by/2`, and changeset casts all accept the canonical 36-char human-readable UUID form (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) directly. The underlying postgres column is `uuid`, and the Ecto adapter handles dump/load between the string form and the raw 16-byte binary internally. ## Optional input validation If you want to reject malformed IDs before hitting the database (avoiding a Postgres error or `Ecto.Query.CastError`), use `Ecto.UUID.cast/1`: ```elixir case Ecto.UUID.cast(id) do {:ok, uuid} -> Repo.get!(Endpoint, uuid) :error -> {:error, :not_found} end ``` `cast/1` returns `{:ok, uuid_string}` for valid canonical UUIDs (or a 16-byte raw binary, which it normalizes back to the string form) and `:error` otherwise. The bang variant `Ecto.UUID.cast!/1` raises `Ecto.CastError` instead. Note one quirk documented in Ecto.UUID: any arbitrary 16-byte binary will cast successfully because both binaries and strings share the same underlying representation in Erlang. ## Prevention via schema field types The cleanest fix is structural: rely on Ecto's changeset cast to convert strings into the right type, so handlers never need manual coercion. Define schemas with explicit binary_id fields: ```elixir @primary_key {:uuid, UUIDv7, autogenerate: true} @foreign_key_type :binary_id schema "endpoints" do belongs_to :workspace, Workspace, foreign_key: :workspace_uuid, references: :uuid, type: :binary_id end ``` And default new migrations to binary_id in `config.exs`: ```elixir config :my_app, MyApp.Repo, migration_primary_key: [name: :uuid, type: :binary_id], migration_foreign_key: [column: :uuid, type: :binary_id] ``` ## Places to audit when migrating to UUID PKs After flipping the primary key type, grep the codebase for all of these — each is a likely crash site: - `String.to_integer` and `:erlang.binary_to_integer/1` on any `id`, `_id`, or `_uuid` param - `Integer.parse/1` used on ID values - LiveView `phx-value-id` handlers that pattern-match `%{"id" => id}` and then coerce - Router `forward`s or plugs that route on numeric ID shape - Custom `Phoenix.Param` implementations that returned `to_string(id)` from an integer - Test fixtures using integer literals as IDs (`%{id: 1}`) - Hand-written SQL fragments in Ecto `fragment("id = ?", ...)` queries - JSON serializers casting IDs to numbers - Frontend code (JS, LiveView hooks) expecting numeric IDs in HEEx `phx-value-*` - Cache keys built from `Integer.to_string/1` on IDs ## Related This is the same family of bug as forgetting to update `@foreign_key_type` after switching to binary_id — the parent schema generates UUIDs but child schemas still try to coerce them through `:id`, producing `Ecto.ChangeError` at insert time. See also pgvector: PostgreSQL Extension for Vector Similarity Search for another example of an extension type that needs schema-side wiring to flow correctly through Ecto casts.