- Add ## Contents and ## Decision Tree to all 10 existing pattern files - Fix embed_as/1 semantics inversion in types.md (:self → :dump) - Fix fabricated __meta__.changes reference in changesets.md - Fix default primary key type (:integer → :id) in schemas.md - Combine @impl subsections into single "Minimal Callback Annotation"
26 KiB
Ecto Type Patterns
Patterns extracted from Ecto's type system source code.
Contents
use Ecto.Type— The Four-Callback Custom Typeembed_as/1— Controlling Embedded Serializationequal?/2— Custom Equality for Change DetectionEcto.Enum— Constrained Atom FieldsEcto.ParameterizedType— Types with Options- Schemaless Types —
{data, types}Changesets
1. use Ecto.Type — The Four-Callback Custom Type
Source: lib/ecto/type.ex#L57-L89
The canonical custom type implements exactly four callbacks. Each has a distinct role in the data flow:
type/0— declares the underlying database column type (:string,:map,:integer, etc.)cast/1— converts external or user-supplied values into your Elixir type; called byEcto.Changeset.cast/4load/1— converts a raw database value into your Elixir type; called when reading rowsdump/1— converts your Elixir type into a database-compatible value; called when writing
The state diagram is: external --[cast]--> internal --[dump]--> database --[load]--> internal
defmodule EctoURI do
use Ecto.Type
def type, do: :map
def cast(uri) when is_binary(uri), do: {:ok, URI.parse(uri)}
def cast(%URI{} = uri), do: {:ok, uri}
def cast(_), do: :error
def load(data) when is_map(data) do
data = for {key, val} <- data, do: {String.to_existing_atom(key), val}
{:ok, struct!(URI, data)}
end
def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)}
def dump(_), do: :error
end
Why: Ecto calls each callback at a different point in the lifecycle. Without cast/1, user input passes through as raw strings. Without load/1, the value comes out of the DB as a plain map. Without dump/1, Ecto cannot serialize the struct for storage. Implementing all four closes every gap in the round-trip.
Anti-pattern: Relying on the database to store your Elixir struct as-is, or using :map with manual pre/post processing scattered across contexts:
# BAD — manual conversion at every call site instead of centralizing in a type
%Post{} = Repo.get(Post, id)
uri = post.url |> Map.from_struct() |> then(&URI.parse/1) # repeated everywhere
Repo.update(Post.changeset(post, %{url: Map.from_struct(new_uri)}))
When to Use
Triggers:
- You want to store an Elixir struct (URI, Decimal, custom struct) in a single database column
- Cast/load/dump logic would otherwise be duplicated across changesets and contexts
- You want Ecto changesets to accept raw user input and produce the correct Elixir type automatically
Example — before:
# Manual conversion in every changeset
def changeset(post, params) do
post
|> cast(params, [:url])
|> validate_change(:url, fn :url, val ->
case URI.parse(val) do
%URI{host: nil} -> [url: "must be a valid URI"]
_ -> []
end
end)
end
# Manual conversion when reading
def get_post(id) do
post = Repo.get!(Post, id)
%{post | url: struct!(URI, post.url)}
end
Example — after:
# schema declaration — one line
field :url, EctoURI
# changeset just casts; EctoURI.cast/1 validates shape
def changeset(post, params), do: cast(post, params, [:url])
# reading returns %URI{} automatically via EctoURI.load/1
def get_post(id), do: Repo.get!(Post, id)
When NOT to Use
Don't use this when:
- The transformation is trivial (storing a plain string that needs no parsing)
- The field contains highly variable or polymorphic data better handled by embedded schemas
- You need association semantics (use Ecto associations, not custom types)
Over-application example:
# Overkill — a custom type just to upcase strings
defmodule UppercaseString do
use Ecto.Type
def type, do: :string
def cast(v) when is_binary(v), do: {:ok, String.upcase(v)}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
end
Better alternative:
# Changeset validation handles this cleanly without a new type module
def changeset(record, params) do
record
|> cast(params, [:code])
|> update_change(:code, &String.upcase/1)
end
Why: Custom types are for encapsulating a serialization contract that must be enforced consistently at every read and write. For one-off transformations or validations, changeset functions are simpler and more obvious to future readers.
2. embed_as/1 — Controlling Embedded Serialization
Source: lib/ecto/type.ex#L104-L109
When a custom type is used inside an embeds_one or embeds_many field, Ecto calls embed_as/1 to decide whether to pass the value through dump/1 or treat it as its own serialized form. The callback receives the embed format (:json by default) and returns either :self or :dump.
:self— the in-memory value is used as-is without callingdump/1; appropriate when the runtime representation is already JSON-compatible (scalars, plain maps):dump—dump/1is called to serialize the value before encoding; needed when the runtime representation (e.g., a struct) is not directly JSON-serializable
use Ecto.Type provides a default implementation that returns :self. Override it to return :dump when your type holds an Elixir struct or other value that cannot be directly encoded to JSON.
defmodule EctoURI do
use Ecto.Type
# Override: %URI{} is not JSON-serializable, so run dump/1 in embedded contexts
def embed_as(_format), do: :dump
end
Why: When Ecto builds embedded documents for export (e.g., storing a JSON blob), it needs to know whether to trust the in-memory value or to re-serialize it. If your type holds state in an Elixir struct that cannot be stored directly (like %URI{}), the default :self passes the raw struct to the JSON encoder — which either raises or produces garbage like %{__struct__: "Elixir.URI", host: ..., ...}. Returning :dump ensures dump/1 converts it to a clean map first.
Anti-pattern: Assuming the default :self is correct for a type whose in-memory and storable representations differ, then debugging mysterious embedded schema corruption:
# BAD — %URI{} stored as a raw struct map because embed_as/1 was never considered
embeds_one :metadata, Metadata do
field :canonical_url, EctoURI # stored as %{__struct__: "Elixir.URI", host: ..., ...}
end
When to Use
Triggers:
- Your custom type is used inside
embeds_oneorembeds_manyfields - The Elixir representation and the storable representation differ (struct vs plain map, etc.)
- You observe that embedded fields are persisted with unexpected shapes
Example — before:
# embed_as/1 not considered; embedded URL stored as raw struct map
defmodule EctoURI do
use Ecto.Type
def type, do: :map
def cast(uri) when is_binary(uri), do: {:ok, URI.parse(uri)}
def cast(%URI{} = uri), do: {:ok, uri}
def cast(_), do: :error
def load(data) when is_map(data) do
data = for {key, val} <- data, do: {String.to_existing_atom(key), val}
{:ok, struct!(URI, data)}
end
def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)}
def dump(_), do: :error
end
Example — after:
defmodule EctoURI do
use Ecto.Type
def type, do: :map
def cast(uri) when is_binary(uri), do: {:ok, URI.parse(uri)}
def cast(%URI{} = uri), do: {:ok, uri}
def cast(_), do: :error
def load(data) when is_map(data) do
data = for {key, val} <- data, do: {String.to_existing_atom(key), val}
{:ok, struct!(URI, data)}
end
def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)}
def dump(_), do: :error
# Explicit: ensure dump/1 runs in embedded contexts to produce a clean map
def embed_as(_format), do: :dump
end
When NOT to Use
Don't use this when:
- Your type is never used in embedded schemas (the callback has no effect)
- The in-memory and storable representations are the same (plain maps, scalars)
- You intentionally want to skip
dump/1in embedded contexts (the default:selfalready does this)
Over-application example:
# Overriding embed_as/1 on a type that stores plain integers
defmodule PriorityLevel do
use Ecto.Type
def type, do: :integer
def cast(v) when is_integer(v) and v in 1..5, do: {:ok, v}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
def embed_as(_), do: :self # Pointless — integer is its own storable form
end
Better alternative:
# Default embed_as/1 from `use Ecto.Type` is sufficient for scalar types
defmodule PriorityLevel do
use Ecto.Type
def type, do: :integer
def cast(v) when is_integer(v) and v in 1..5, do: {:ok, v}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
end
Why: embed_as/1 is only meaningful when there is a gap between what Ecto holds in memory and what must be written to the database. For scalar types where the two representations are identical, the default suffices and adding the override is noise.
3. equal?/2 — Custom Equality for Change Detection
Source: lib/ecto/type.ex
Ecto calls equal?/2 to decide whether a field's value has changed before including it in an UPDATE statement. The default delegates to ==. Override when your type's notion of equality is more nuanced than structural term equality.
Common cases: Decimal values where Decimal.equal?/2 differs from ==, sets or maps where insertion order shouldn't matter, floats with tolerance, URIs where trailing slashes are equivalent.
defmodule EctoDecimal do
use Ecto.Type
def type, do: :string
def equal?(%Decimal{} = a, %Decimal{} = b), do: Decimal.equal?(a, b)
def equal?(a, b), do: a == b
end
Why: Decimal.new("1.0") == Decimal.new("1.00") is false in Elixir because the structs differ structurally. Without a custom equal?/2, Ecto would generate a spurious UPDATE every time a Decimal field is loaded and re-saved unchanged. The custom implementation delegates to the type's own equality semantics, preventing unnecessary database writes.
Anti-pattern: Allowing Ecto to generate spurious UPDATEs because == disagrees with logical equality:
# BAD — default equal?/2 uses ==
# Decimal.new("1.0") != Decimal.new("1.00") structurally,
# so every load-and-save cycle marks the field dirty
defmodule EctoDecimal do
use Ecto.Type
def type, do: :string
def cast(v), do: {:ok, Decimal.new(v)}
def load(v), do: {:ok, Decimal.new(v)}
def dump(v), do: {:ok, Decimal.to_string(v)}
# Missing equal?/2 — spurious UPDATEs in production
end
When to Use
Triggers:
- Your type wraps a value with non-structural equality (Decimal, Set, custom structs with computed fields)
- You see unexpected
UPDATEqueries in your logs when no meaningful data changed - The type's own library provides an equality function (e.g.,
Decimal.equal?/2)
Example — before:
# Tags stored as a sorted list — ["a", "b"] and ["b", "a"] treated as different
defmodule TagList do
use Ecto.Type
def type, do: {:array, :string}
def cast(tags) when is_list(tags), do: {:ok, Enum.sort(tags)}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
# Missing equal?/2 — if DB returns unsorted list, it always appears changed
end
Example — after:
defmodule TagList do
use Ecto.Type
def type, do: {:array, :string}
def cast(tags) when is_list(tags), do: {:ok, Enum.sort(tags)}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
def equal?(a, b) when is_list(a) and is_list(b) do
Enum.sort(a) == Enum.sort(b)
end
def equal?(a, b), do: a == b
end
When NOT to Use
Don't use this when:
- Structural equality (
==) already matches your type's logical equality (most scalars, plain maps) - You intentionally want every change in representation to trigger an update (audit fields, version counters)
- The equality logic would require database queries or I/O
Over-application example:
# Overriding equal?/2 for a plain string type — == is already correct
defmodule TrimmedString do
use Ecto.Type
def type, do: :string
def cast(v) when is_binary(v), do: {:ok, String.trim(v)}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
def equal?(a, b), do: String.trim(a) == String.trim(b) # misleading
end
Better alternative:
# cast/1 already normalizes; == on the normalized value is correct
defmodule TrimmedString do
use Ecto.Type
def type, do: :string
def cast(v) when is_binary(v), do: {:ok, String.trim(v)}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
# Default equal?/2 is correct — both sides are already trimmed
end
Why: Once cast/1 normalizes values to a canonical form, == on that canonical form is correct. Overriding equal?/2 to re-normalize creates two sources of truth for what "equal" means and can hide bugs where values escape normalization.
4. Ecto.Enum — Constrained Atom Fields
Source: lib/ecto/enum.ex
Ecto.Enum is Ecto's built-in parameterized type for fields that hold one of a fixed set of values. It stores atoms as strings in the database, validates membership automatically during cast/4, and provides a clean schema-level declaration of what values are legal.
schema "orders" do
field :status, Ecto.Enum, values: [:pending, :processing, :shipped, :delivered, :cancelled]
end
# Changeset validation is automatic:
# cast will reject values outside the enum
# DB stores as "pending", "processing", etc.
# Can also map atoms to custom DB values:
field :status, Ecto.Enum,
values: [pending: "PENDING", shipped: "SHIPPED"]
Why: A fixed set of valid values is a domain constraint that belongs at the type level, not scattered across changeset validations. Ecto.Enum co-locates the constraint with the field declaration, making it impossible to add a new status without updating the schema. It also avoids the string-vs-atom impedance mismatch: your application code works with atoms, the DB stores strings.
Anti-pattern: Using validate_inclusion with a hardcoded list when the field is a fixed enum. The valid values now live in two places (schema field type and changeset validation), drift over time, and offer no guarantee that the DB stores a normalized form:
# BAD — validation is disconnected from the field type
schema "orders" do
field :status, :string
end
def changeset(order, params) do
order
|> cast(params, [:status])
|> validate_inclusion(:status, ["pending", "processing", "shipped"])
# Atoms vs strings already a smell; easy to add a value in one place but not the other
end
When to Use
Triggers:
- A field can only hold one of a fixed, small set of named values (status, role, priority, state machine states)
- You want
cast/4to reject invalid values without writing a custom validator - You want the valid values to be introspectable at runtime via
Ecto.Enum.values/2
Example — before:
schema "tickets" do
field :priority, :string
end
@valid_priorities ~w(low medium high critical)
def changeset(ticket, params) do
ticket
|> cast(params, [:priority])
|> validate_required([:priority])
|> validate_inclusion(:priority, @valid_priorities)
end
Example — after:
schema "tickets" do
field :priority, Ecto.Enum, values: [:low, :medium, :high, :critical]
end
def changeset(ticket, params) do
ticket
|> cast(params, [:priority])
|> validate_required([:priority])
# validate_inclusion is implicit — cast rejects invalid values
end
When NOT to Use
Don't use this when:
- The set of valid values is dynamic (loaded from the database, configurable at runtime)
- You need rich metadata per value (labels, descriptions, ordering weights) — a separate table or config map is better
- The field is an open-ended string that happens to have common values (tags, categories that grow)
Over-application example:
# Enum for country codes that may expand and need localization
field :country, Ecto.Enum, values: [:us, :gb, :de, :fr, :jp]
# Adding a new country requires a schema migration AND a code change
Better alternative:
# Reference table with a foreign key
field :country_code, :string
# Validated against a countries table at the application layer
Why: Ecto.Enum hard-codes valid values into the schema module. When the list is stable and small (status machines, role levels), that is exactly right. When the list is user-managed or requires non-code changes to extend, a reference table decouples the constraint from deployments.
5. Ecto.ParameterizedType — Types with Options
Source: lib/ecto/parameterized_type.ex
When a custom type needs configuration options set at field definition time (like Ecto.Enum's values: option), implement Ecto.ParameterizedType instead of Ecto.Type. The init/1 callback receives field options at compile/load time and returns a params term that is threaded through every other callback at runtime.
The five callbacks: init/1, type/1, cast/2, load/3, dump/3.
defmodule MyApp.StatusType do
use Ecto.ParameterizedType
def init(opts) do
validate = Keyword.fetch!(opts, :validate)
%{validate: validate}
end
def type(_params), do: :string
def cast(value, %{validate: validator}) do
if validator.(value), do: {:ok, value}, else: :error
end
def load(value, _loader, _params), do: {:ok, value}
def dump(value, _dumper, _params), do: {:ok, value}
end
# Usage:
field :status, MyApp.StatusType, validate: &(&1 in ~w(a b c))
Why: Ecto.Type callbacks receive no configuration — the type module is a singleton with fixed behavior. Ecto.ParameterizedType solves the case where the same type module should behave differently per field (different valid values, different validation rules, different storage formats). Ecto.Enum itself is implemented as a parameterized type so the same module handles every values: list.
Anti-pattern: Defining a new type module for every variation of behavior that differs only in configuration:
# BAD — combinatorial explosion of modules
defmodule StatusType.V1 do
use Ecto.Type
@valid ~w(draft published)
def cast(v) when v in @valid, do: {:ok, v}
def cast(_), do: :error
# ...
end
defmodule StatusType.V2 do
use Ecto.Type
@valid ~w(open in_progress closed)
# identical code, different @valid
end
When to Use
Triggers:
- The same type logic should apply to multiple fields but with different configuration per field
- You are building a reusable library type that schema authors customize via options
- The configuration is known at schema definition time (compile-time or application startup)
Example — before:
# A separate module per enum — doesn't scale
defmodule OrderStatus do
use Ecto.Type
def type, do: :string
def cast(v) when v in ~w(pending shipped), do: {:ok, v}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
end
defmodule TicketPriority do
use Ecto.Type
def type, do: :string
def cast(v) when v in ~w(low high), do: {:ok, v}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
end
Example — after:
defmodule ConstrainedString do
use Ecto.ParameterizedType
def init(opts), do: %{values: Keyword.fetch!(opts, :values)}
def type(_params), do: :string
def cast(value, %{values: values}) when value in values, do: {:ok, value}
def cast(_, _), do: :error
def load(value, _, _), do: {:ok, value}
def dump(value, _, _), do: {:ok, value}
end
# In schemas:
field :status, ConstrainedString, values: ~w(pending shipped)
field :priority, ConstrainedString, values: ~w(low high)
When NOT to Use
Don't use this when:
- The type has no configuration — use
Ecto.Type, which has simpler callback signatures - The configuration changes at runtime rather than at schema definition time (use
cast/1with runtime state instead) - You only need this for one field in one schema (inline the logic in a changeset validator)
Over-application example:
# Parameterized type for a type that has no real options
defmodule MaybeString do
use Ecto.ParameterizedType
def init(_opts), do: %{} # no options used
def type(_), do: :string
def cast(v, _) when is_binary(v), do: {:ok, v}
def cast(_, _), do: :error
def load(v, _, _), do: {:ok, v}
def dump(v, _, _), do: {:ok, v}
end
Better alternative:
# No configuration needed — plain Ecto.Type is simpler
defmodule MaybeString do
use Ecto.Type
def type, do: :string
def cast(v) when is_binary(v), do: {:ok, v}
def cast(_), do: :error
def load(v), do: {:ok, v}
def dump(v), do: {:ok, v}
end
Why: Ecto.ParameterizedType adds arity to every callback (params argument) and requires an init/1 that must be implemented. This complexity is justified when you need per-field configuration. Without actual options, the extra arity is noise that obscures intent.
6. Schemaless Types — {data, types} Changesets
Source: lib/ecto/changeset.ex (documentation for cast/4)
When you need to validate and cast data without defining a full schema module, pass a {data, types} tuple as the first argument to Ecto.Changeset.cast/4. The data map holds the current values (often %{}), and the types map specifies field names and their Ecto types. The full changeset API works normally — validate_required, validate_number, apply_action, etc.
def validate_params(params) do
types = %{name: :string, age: :integer, role: :string}
{%{}, types}
|> Ecto.Changeset.cast(params, Map.keys(types))
|> Ecto.Changeset.validate_required([:name])
|> Ecto.Changeset.validate_number(:age, greater_than: 0)
|> Ecto.Changeset.apply_action(:insert)
end
Why: Defining a use Ecto.Schema module for a one-off params map is heavyweight: it introduces a new module, struct, and migration concern for data that never touches the database. The {data, types} tuple gives you Ecto's casting and validation pipeline — type coercion, error accumulation, apply_action — for ephemeral, transient, or API-boundary data structures.
Anti-pattern: Defining a full schema module solely to validate a one-off params map:
# BAD — a schema with no table, used only for one validation function
defmodule SearchParams do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :query, :string
field :page, :integer
field :per_page, :integer
end
def changeset(params) do
%SearchParams{}
|> cast(params, [:query, :page, :per_page])
|> validate_required([:query])
end
end
When to Use
Triggers:
- Validating and casting controller params, webhook payloads, or CLI arguments that are never persisted
- Building a multi-step form or wizard where intermediate steps don't map to a database row
- Writing a context function that accepts a params map and needs to return normalized data or errors
Example — before:
# Full schema module for transient data
defmodule ReportFilter do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :start_date, :date
field :end_date, :date
field :user_id, :integer
end
def changeset(params) do
%ReportFilter{}
|> cast(params, [:start_date, :end_date, :user_id])
|> validate_required([:start_date, :end_date])
end
end
Example — after:
def parse_report_filter(params) do
types = %{start_date: :date, end_date: :date, user_id: :integer}
{%{}, types}
|> Ecto.Changeset.cast(params, Map.keys(types))
|> Ecto.Changeset.validate_required([:start_date, :end_date])
|> Ecto.Changeset.apply_action(:validate)
end
When NOT to Use
Don't use this when:
- The data structure is reused across many contexts (define a proper schema or embedded schema)
- You need associations,
autogenerate, ortimestamps()(requires a full schema) - The validated data will eventually be persisted (start with a schema to avoid a rewrite)
Over-application example:
# Schemaless changeset for data that has a natural schema home
def update_user_profile(user_id, params) do
types = %{name: :string, email: :string, bio: :string}
{%{}, types}
|> Ecto.Changeset.cast(params, Map.keys(types))
|> Ecto.Changeset.validate_required([:name, :email])
|> Ecto.Changeset.apply_action(:update)
# Then manually build an update query — loses Ecto.Repo integration
end
Better alternative:
# User already has a schema — use it
def update_user_profile(user_id, params) do
Repo.get!(User, user_id)
|> User.profile_changeset(params)
|> Repo.update()
end
Why: The {data, types} approach trades schema infrastructure for simplicity. That trade is worth it for truly transient data (search filters, report parameters, one-time import validation). For persistent data, the schema module is not overhead — it is the source of truth for what the table contains.
Decision Tree
- If storing a fixed set of values →
Ecto.Enumwithvalues: - If wrapping an Elixir struct (URI, Decimal, etc.) in a field →
use Ecto.Typewith all four callbacks - If your type needs configuration options at the field level →
use Ecto.ParameterizedType - If two values of your type may be logically equal without
==→ overrideequal?/2 - If your type is used inside
embeds_one/embeds_manyand has a non-trivial serialized form → verifyembed_as/1 - If you need to validate params without a schema → schemaless
{data, types}changeset