Files
aweiker 10218813d3 docs: backfill TOC + decision trees, fix review findings
- 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"
2026-05-01 22:13:35 -07:00

30 KiB

Ecto Schema Patterns

Patterns extracted from lib/ecto/schema.ex in the Ecto source.

Contents

  1. Base Schema Module — App-Wide Schema Defaults
  2. @primary_key false — Composite or No Primary Key
  3. Virtual Fields — In-Memory-Only Data
  4. embedded_schema/1 — Schemaless Validation Structs
  5. @timestamps_opts — Consistent Timestamp Types
  6. Field :source Option — Column Name Mapping
  7. redact: true — Protecting Sensitive Fields
  8. __schema__/1 Reflection — Runtime Schema Introspection

1. Base Schema Module — App-Wide Schema Defaults

Source: lib/ecto/schema.ex#L194

defmodule MyApp.Schema do
  defmacro __using__(_) do
    quote do
      use Ecto.Schema
      @primary_key {:id, :binary_id, autogenerate: true}
      @foreign_key_type :binary_id
    end
  end
end

defmodule MyApp.Comment do
  use MyApp.Schema
  schema "comments" do
    belongs_to :post, MyApp.Post
  end
end

Why: @primary_key and @foreign_key_type are module attributes that must be set before each schema/2 block. Without a shared base module, every schema in the application must repeat these two lines. One forgotten schema silently gets integer :id columns and integer foreign keys — a type mismatch that only surfaces at runtime when joins or associations break.

Anti-pattern: Setting @primary_key in every schema individually:

# BAD — duplicated in every schema, easy to miss in a new module
defmodule MyApp.Comment do
  use Ecto.Schema
  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "comments" do
    belongs_to :post, MyApp.Post
  end
end

defmodule MyApp.Post do
  use Ecto.Schema
  # Forgot to set @primary_key — now uses integer :id
  schema "posts" do
    has_many :comments, MyApp.Comment  # Association type mismatch
  end
end

When to Use

Triggers:

  • Application uses UUID (:binary_id) primary keys
  • Application has more than one schema module
  • You want all future schemas to inherit the same defaults without manual setup

Example — before:

defmodule MyApp.User do
  use Ecto.Schema
  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "users" do
    field :name, :string
  end
end

defmodule MyApp.Post do
  use Ecto.Schema
  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "posts" do
    belongs_to :user, MyApp.User
  end
end

Example — after:

# lib/my_app/schema.ex — define once
defmodule MyApp.Schema do
  defmacro __using__(_) do
    quote do
      use Ecto.Schema
      @primary_key {:id, :binary_id, autogenerate: true}
      @foreign_key_type :binary_id
    end
  end
end

# All schemas use the base module
defmodule MyApp.User do
  use MyApp.Schema
  schema "users" do
    field :name, :string
  end
end

defmodule MyApp.Post do
  use MyApp.Schema
  schema "posts" do
    belongs_to :user, MyApp.User
  end
end

When NOT to Use

Don't use this when:

  • The application uses integer primary keys (Ecto's default) — no base module needed
  • Different schemas intentionally use different primary key types
  • You only have one schema (the base module adds indirection for no gain)

Over-application example:

# Unnecessary when using integer primary keys (the Ecto default)
defmodule MyApp.Schema do
  defmacro __using__(_) do
    quote do
      use Ecto.Schema
      @primary_key {:id, :id, autogenerate: true}  # Same as the default (:id resolves to integer)
      @foreign_key_type :id
    end
  end
end

Better alternative:

# Just use Ecto.Schema directly when the defaults are fine
defmodule MyApp.Post do
  use Ecto.Schema
  schema "posts" do
    field :title, :string
  end
end

Why: The base module pattern exists to override Ecto defaults, not to mirror them. When integer primary keys are acceptable, the base module adds a layer of indirection without adding any value.


2. @primary_key false — Composite or No Primary Key

Source: lib/ecto/schema.ex#L1525

defmodule PostTag do
  use Ecto.Schema
  @primary_key false
  schema "posts_tags" do
    belongs_to :post, Post
    belongs_to :tag, Tag
  end
end

Why: By default, Ecto injects an :id field as the first field in every schema. For join tables with composite primary keys — or tables that intentionally have no primary key — this auto-injected field causes problems: migrations add a spurious column, queries include it, and Ecto's identity tracking operates on the wrong key. Setting @primary_key false disables the auto-injection entirely, giving the schema full control over which fields exist.

Anti-pattern: Defining a join table schema with the default :id field:

# BAD — Ecto injects :id, which doesn't exist in posts_tags
defmodule PostTag do
  use Ecto.Schema
  schema "posts_tags" do
    belongs_to :post, Post
    belongs_to :tag, Tag
  end
end
# Migration adds an :id column that the DB or application doesn't need.
# Ecto load/insert operations will reference a non-meaningful primary key.

When to Use

Triggers:

  • The table is a join/association table with a composite primary key (e.g., post_id + tag_id)
  • The table has no meaningful single-column primary key at all
  • You're mapping a legacy table that lacks a primary key column

Example — before:

# posts_tags table has only (post_id, tag_id) — no separate :id column
defmodule PostTag do
  use Ecto.Schema
  schema "posts_tags" do
    belongs_to :post, Post     # generates post_id
    belongs_to :tag, Tag       # generates tag_id
    timestamps()
  end
end
# Ecto injects :id — but the DB column doesn't exist, causing query errors.

Example — after:

defmodule PostTag do
  use Ecto.Schema
  @primary_key false
  schema "posts_tags" do
    belongs_to :post, Post
    belongs_to :tag, Tag
    timestamps()
  end
end

When NOT to Use

Don't use this when:

  • The table has a legitimate single-column primary key (use the default or @primary_key {:id, :binary_id, autogenerate: true})
  • You want a composite primary key but still need Ecto's Repo.get/2 and identity-based operations — those rely on a single primary key field
  • The schema is used with Repo.get/2 or Repo.update/2, which require a primary key

Over-application example:

# Disabling primary key on a regular entity schema
defmodule MyApp.User do
  use Ecto.Schema
  @primary_key false          # Users need a primary key for Repo.get/2
  schema "users" do
    field :email, :string
    field :name, :string
  end
end

Better alternative:

defmodule MyApp.User do
  use Ecto.Schema
  schema "users" do            # Default :id primary key is correct here
    field :email, :string
    field :name, :string
  end
end

Why: @primary_key false removes Ecto's ability to uniquely identify rows. Repo.get/2, Repo.update/2, and association loading all depend on a primary key. Use it only for tables that are genuinely identified by composite keys or have no identity concept.


3. Virtual Fields — In-Memory-Only Data

Source: lib/ecto/schema.ex#L332

schema "users" do
  field :password_hash, :string
  field :password, :string, virtual: true
  field :delete, :boolean, virtual: true
end

Why: Virtual fields live on the struct and participate in changesets — they can be cast, validate_required, and read in changeset functions — but Ecto never attempts to load or persist them. This is exactly right for data that only exists for the duration of an operation: a plaintext password for hashing, a confirmation field, a checkbox that signals intent to delete, or a computed display value assembled in application code.

Anti-pattern: Storing transient data in a map or separate variable passed alongside the changeset:

# BAD — passing raw password alongside changeset is error-prone
def register_user(params) do
  changeset = User.changeset(%User{}, params)
  password = Map.get(params, "password")   # Not validated, not in changeset pipeline
  if changeset.valid?, do: hash_and_insert(changeset, password)
end

When to Use

Triggers:

  • A field is needed in changeset validation but must never be persisted (passwords, confirmation fields)
  • A value is computed in application code and attached to the struct for rendering but has no DB column
  • A form sends a signal (e.g., a "delete" checkbox) that controls behavior rather than being stored

Example — before:

# Confirmation field handled outside the changeset pipeline
def create_account(params) do
  password = params["password"]
  confirm  = params["password_confirmation"]

  if password != confirm do
    {:error, "passwords do not match"}
  else
    User.changeset(%User{}, params) |> Repo.insert()
  end
end

Example — after:

defmodule User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :password_hash, :string
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true
  end

  def registration_changeset(user, params) do
    user
    |> cast(params, [:email, :password, :password_confirmation])
    |> validate_required([:email, :password])
    |> validate_confirmation(:password)
    |> hash_password()
  end

  defp hash_password(%{valid?: true, changes: %{password: pw}} = changeset) do
    put_change(changeset, :password_hash, Bcrypt.hash_pwd_salt(pw))
  end
  defp hash_password(changeset), do: changeset
end

When NOT to Use

Don't use this when:

  • The value should be persisted and loaded from the DB (use a regular field)
  • The value needs to participate in Ecto queries (where, order_by) — virtual fields cannot be queried
  • The field has a :default that should be the DB column default — virtual field defaults only affect the struct, not the database

Over-application example:

# Using virtual for a value that should live in the DB
schema "articles" do
  field :title, :string
  field :slug, :string, virtual: true   # Wrong — slug needs to be persisted and queried
  field :body, :string
end

Better alternative:

schema "articles" do
  field :title, :string
  field :slug, :string                  # Regular field — persisted and queryable
  field :body, :string
end

Why: Virtual fields are intentionally invisible to the database layer. Any field that needs to survive a page reload, be searched, sorted, or returned in a list query must be a real column.


4. embedded_schema/1 — Schemaless Validation Structs

Source: lib/ecto/schema.ex#L110

defmodule Search do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :query, :string
    field :page, :integer, default: 1
    field :per_page, :integer, default: 20
  end

  def changeset(search, params) do
    search
    |> cast(params, [:query, :page, :per_page])
    |> validate_required([:query])
    |> validate_number(:page, greater_than: 0)
  end
end

Why: embedded_schema defines a schema module with no backing DB table. The struct, changeset pipeline, and all validations work identically to a regular schema, but there is no Repo involved. This makes it the right tool for validating and casting data that doesn't map to a table: search/filter forms, API request bodies, command parameters, multi-step wizard state. A Search.changeset/2 call returns a changeset with errors just like User.changeset/2 does — the same controller and form helper code works for both.

Anti-pattern: Validating formless data with raw Map operations and ad-hoc checks:

# BAD — manual validation without changeset pipeline
def search(params) do
  query = Map.get(params, "query")
  page  = Map.get(params, "page", "1") |> String.to_integer()

  if is_nil(query) or query == "" do
    {:error, "query is required"}
  else
    {:ok, %{query: query, page: page}}
  end
  # No type coercion, no error accumulation, no `valid?` flag
end

When to Use

Triggers:

  • A form or API endpoint needs validated, typed input but no persistence
  • You want Ecto.Changeset error formatting and valid? semantics without a Repo
  • Multi-step wizard where intermediate state needs validation before DB write
  • An external API receives a complex payload that needs casting and validation

Example — before:

# Validating filter params without Ecto — verbose and fragile
def filter_posts(params) do
  status = Map.get(params, "status")
  valid_statuses = ["draft", "published", "archived"]

  cond do
    status not in valid_statuses ->
      {:error, "status must be one of: #{Enum.join(valid_statuses, ", ")}"}
    true ->
      Post |> where(status: ^status) |> Repo.all() |> then(&{:ok, &1})
  end
end

Example — after:

defmodule PostFilter do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :status, Ecto.Enum, values: [:draft, :published, :archived]
    field :page, :integer, default: 1
  end

  def changeset(filter \\ %PostFilter{}, params) do
    filter
    |> cast(params, [:status, :page])
    |> validate_required([:status])
    |> validate_number(:page, greater_than: 0)
  end
end

def filter_posts(params) do
  with %{valid?: true} = cs <- PostFilter.changeset(params),
       filter <- Ecto.Changeset.apply_changes(cs) do
    Post |> where(status: ^filter.status) |> Repo.all() |> then(&{:ok, &1})
  else
    cs -> {:error, cs}
  end
end

When NOT to Use

Don't use this when:

  • The data needs to be persisted — use a regular schema/2 with a migration
  • The validation logic is trivial (one or two fields) — a plain cast + validate_required in a context module may be sufficient
  • You need DB-level constraints or uniqueness checks — embedded_schema has no Repo access

Over-application example:

# Overkill for a single required string field
defmodule SearchQuery do
  use Ecto.Schema
  embedded_schema do
    field :q, :string
  end
  def changeset(s, p), do: s |> cast(p, [:q]) |> validate_required([:q])
end

Better alternative:

# Simple inline validation is clearer for trivial cases
def search(params) do
  case Map.fetch(params, "q") do
    {:ok, q} when q != "" -> {:ok, q}
    _ -> {:error, :missing_query}
  end
end

Why: embedded_schema earns its keep when you have multiple fields, type coercion, or multiple validators — the changeset pipeline pays for itself. For a single field with one constraint, the ceremony outweighs the benefit.


5. @timestamps_opts — Consistent Timestamp Types

Source: lib/ecto/schema.ex#L170

defmodule MyApp.Schema do
  defmacro __using__(_) do
    quote do
      use Ecto.Schema
      @timestamps_opts [type: :utc_datetime_usec]
    end
  end
end

Why: The timestamps() macro inserts inserted_at and updated_at fields whose type is controlled by @timestamps_opts. The default type is :naive_datetime, which stores timestamps with no timezone information. Applications that store or compare timestamps across timezones need :utc_datetime or :utc_datetime_usec. Microsecond precision (:utc_datetime_usec) avoids silent truncation when high-resolution timestamps are generated in Elixir and stored in PostgreSQL (which supports microsecond precision). Setting this in the base module ensures every schema uses the same timestamp type.

Anti-pattern: Using the default naive_datetime timestamps when UTC compliance or precision is required:

# BAD — naive_datetime drops timezone context
defmodule MyApp.Post do
  use Ecto.Schema
  schema "posts" do
    field :title, :string
    timestamps()  # inserts inserted_at/updated_at as :naive_datetime
  end
end
# Timestamps stored as "2024-01-15 10:30:00" — no UTC indicator.
# Comparisons and serialization can silently produce wrong results.

When to Use

Triggers:

  • Application stores or compares timestamps across timezones (almost always true for web apps)
  • Timestamps need to be serialized to ISO 8601 / JSON with timezone information
  • PostgreSQL or another DB with microsecond precision should not lose sub-second data
  • Multiple schemas share the same timestamp behavior

Example — before:

# Each schema repeats the option
defmodule MyApp.User do
  use Ecto.Schema
  schema "users" do
    timestamps(type: :utc_datetime_usec)
  end
end

defmodule MyApp.Post do
  use Ecto.Schema
  schema "posts" do
    timestamps(type: :utc_datetime_usec)
  end
end

Example — after:

# Base module sets the default once
defmodule MyApp.Schema do
  defmacro __using__(_) do
    quote do
      use Ecto.Schema
      @primary_key {:id, :binary_id, autogenerate: true}
      @foreign_key_type :binary_id
      @timestamps_opts [type: :utc_datetime_usec]
    end
  end
end

defmodule MyApp.User do
  use MyApp.Schema
  schema "users" do
    timestamps()  # uses :utc_datetime_usec from base module
  end
end

When NOT to Use

Don't use this when:

  • The application intentionally avoids timezones (internal tooling, batch jobs)
  • The DB column type is timestamp without time zone and you want to preserve that semantic
  • Only one or two schemas use timestamps and the option is set inline

Over-application example:

# Setting timestamps_opts for a schema that has no timestamps() call
defmodule PostTag do
  use Ecto.Schema
  @primary_key false
  @timestamps_opts [type: :utc_datetime_usec]   # Never used — no timestamps() call
  schema "posts_tags" do
    belongs_to :post, Post
    belongs_to :tag, Tag
  end
end

Better alternative:

# The option is harmless but unnecessary — omit it when there are no timestamps
defmodule PostTag do
  use MyApp.Schema   # Base module sets it — no need to repeat
  @primary_key false
  schema "posts_tags" do
    belongs_to :post, Post
    belongs_to :tag, Tag
  end
end

Why: @timestamps_opts only takes effect when timestamps() is called inside a schema block. Setting it on schemas that call timestamps() is correct; setting it on schemas that don't is dead code.


6. Field :source Option — Column Name Mapping

Source: lib/ecto/schema.ex

schema "legacy_users" do
  field :email_address, :string, source: :emailaddress
  field :created_at, :utc_datetime, source: :creation_timestamp
end

Why: The :source option on field/3 maps an Elixir field name to a different database column name. This is the escape hatch for working with databases that don't follow Elixir naming conventions — legacy systems with camelCase or abbreviated column names, databases shared with other applications, or tables generated by external tools. The Elixir struct uses the field name (:email_address); SQL uses the source name (emailaddress). Ecto translates transparently in both directions — select, where, and insert all use the column name.

Anti-pattern: Mapping column names by wrapping all query fragments in raw SQL or using fragment/1 everywhere:

# BAD — forced to use raw column names in every query
from u in "legacy_users",
  where: fragment("emailaddress = ?", ^email),
  select: %{email: u.emailaddress, created_at: u.creation_timestamp}
# Loses type casting, association loading, and changeset integration

When to Use

Triggers:

  • The database column name cannot be changed (shared DB, legacy system, migration risk)
  • The column name violates Elixir conventions (camelCase, abbreviations, reserved words)
  • You want Ecto's type casting and query DSL to work with idiomatic Elixir field names

Example — before:

# Schema mirrors the ugly DB column names directly
schema "legacy_users" do
  field :emailaddress, :string
  field :creation_timestamp, :utc_datetime
  field :lastlogindt, :utc_datetime
end

# Every callsite uses the ugly names
from u in User, where: u.emailaddress == ^email
%{emailaddress: user.emailaddress, created: user.creation_timestamp}

Example — after:

schema "legacy_users" do
  field :email, :string, source: :emailaddress
  field :inserted_at, :utc_datetime, source: :creation_timestamp
  field :last_login_at, :utc_datetime, source: :lastlogindt
end

# Callsites use clean Elixir names
from u in User, where: u.email == ^email
%{email: user.email, created: user.inserted_at}

When NOT to Use

Don't use this when:

  • You control the database schema — use a migration to rename the column instead
  • The column name follows Elixir conventions already — :source adds no value
  • You're creating a new table — design the column names correctly from the start

Over-application example:

# Renaming columns that are already idiomatic
schema "users" do
  field :user_name, :string, source: :username      # username is fine as-is
  field :created_at_time, :utc_datetime, source: :inserted_at  # just use inserted_at
end

Better alternative:

schema "users" do
  field :username, :string    # Keep the DB name if it's already clear
  timestamps()                # inserted_at / updated_at are already conventional
end

Why: :source is a mapping layer between two names that creates a permanent translation cost every time someone reads the schema. When you control the DB, eliminate the mismatch at the migration level rather than carrying it forever in the schema.


7. redact: true — Protecting Sensitive Fields

Source: lib/ecto/schema.ex#L128

schema "users" do
  field :email, :string
  field :password_hash, :string, redact: true
  field :api_token, :string, redact: true
end

Why: Ecto derives an Inspect implementation for every schema struct. Without redact: true, inspecting a changeset or struct in logs, IEx, or crash reports prints every field value in plain text. Marking a field with redact: true replaces its value with **redacted** in inspect/2 output and in changeset.changes inspection. This is a passive, always-on protection that requires no additional code at each log site — once set on the field, the field is protected everywhere the struct is printed.

Anti-pattern: Relying on callers to manually omit sensitive fields before logging:

# BAD — easy to forget, must be repeated at every log site
def create_user(params) do
  changeset = User.changeset(%User{}, params)
  # Must remember to drop :password_hash before logging
  safe = Map.drop(changeset.changes, [:password_hash, :api_token])
  Logger.info("Creating user: #{inspect(safe)}")
  Repo.insert(changeset)
end

When to Use

Triggers:

  • A field stores a secret, credential, or token (passwords, API keys, session tokens)
  • A field stores PII that should not appear in logs or error reports (SSN, credit card data)
  • A changeset for the schema might be logged, inspected in IEx, or appear in crash reports
  • You use Logger.info/debug calls that could inadvertently print struct values

Example — before:

# Sensitive values appear in plain text in logs and crash dumps
defmodule User do
  use Ecto.Schema
  schema "users" do
    field :email, :string
    field :password_hash, :string   # Logged as "password_hash: \"$2b$12$...\""
    field :reset_token, :string     # Logged as "reset_token: \"abc123secret\""
  end
end

Example — after:

defmodule User do
  use Ecto.Schema
  schema "users" do
    field :email, :string
    field :password_hash, :string, redact: true
    field :reset_token, :string, redact: true
  end
end
# inspect(user) => #User<email: "alice@example.com", password_hash: **redacted**, ...>

When NOT to Use

Don't use this when:

  • The field contains non-sensitive data — redaction adds visual noise to inspect output
  • You need to see the field value during development debugging (temporarily remove redact: true locally, or use pattern matching to extract the value directly)
  • The struct is used exclusively in contexts where it is never printed (pure computation)

Over-application example:

# Redacting non-sensitive fields makes debugging needlessly difficult
schema "products" do
  field :name, :string, redact: true       # Why is a product name sensitive?
  field :price, :decimal, redact: true     # Price data isn't a secret
  field :sku, :string, redact: true
end

Better alternative:

schema "products" do
  field :name, :string
  field :price, :decimal
  field :sku, :string
end

Why: redact: true makes fields invisible to standard Elixir tooling. Overusing it on non-sensitive fields makes crash reports and debug output harder to read without adding any security benefit. Apply it precisely to fields whose values would constitute a security or privacy exposure if logged.


8. __schema__/1 Reflection — Runtime Schema Introspection

Source: lib/ecto/schema.ex#L450

# Get all field names
User.__schema__(:fields)          #=> [:id, :name, :email, :age]
User.__schema__(:associations)    #=> [:posts, :comments]
User.__schema__(:type, :email)    #=> :string
User.__schema__(:virtual_fields)  #=> [:password]

Why: Every module that calls use Ecto.Schema gets __schema__/1 generated at compile time. These reflection callbacks expose the schema's structure at runtime without requiring the caller to know the schema's field list at compile time. Generic code — CSV exporters, audit loggers, API serializers, test factories — can introspect any schema and work with all its fields automatically. When a new field is added to the schema, the generic code picks it up without modification.

Anti-pattern: Hardcoding field names in generic helpers:

# BAD — must be updated every time any schema gains or loses a field
def audit_changes(changeset) do
  fields = [:name, :email, :role]   # Hardcoded — User-specific, not generic
  Enum.each(fields, fn field ->
    if Map.has_key?(changeset.changes, field) do
      AuditLog.record(field, changeset.changes[field])
    end
  end)
end

When to Use

Triggers:

  • Writing a helper that must work across multiple schema modules without knowing their fields
  • Building a generic CSV exporter, JSON serializer, or audit logger
  • Generating test factories or seed data for any schema
  • Writing a linter or validation tool that checks schema conventions

Example — before:

# Must manually maintain field lists for each schema
defmodule CsvExporter do
  def export_users(records) do
    headers = ["id", "name", "email", "age"]
    rows = Enum.map(records, fn u -> [u.id, u.name, u.email, u.age] end)
    [headers | rows]
  end

  def export_posts(records) do
    headers = ["id", "title", "body", "user_id"]
    rows = Enum.map(records, fn p -> [p.id, p.title, p.body, p.user_id] end)
    [headers | rows]
  end
end

Example — after:

defmodule CsvExporter do
  def export(schema, records) do
    fields = schema.__schema__(:fields)
    headers = Enum.map(fields, &to_string/1)
    rows = Enum.map(records, fn record ->
      Enum.map(fields, &Map.get(record, &1))
    end)
    [headers | rows]
  end
end

# Works for any schema without modification
CsvExporter.export(User, users)
CsvExporter.export(Post, posts)

When NOT to Use

Don't use this when:

  • You're writing code for one specific schema and the fields are known and stable — just use them directly
  • You need to control exactly which fields are exposed (e.g., public API — use an explicit allowlist)
  • The schema has virtual fields or sensitive fields that should not be included — reflection returns all fields regardless of virtual: true or redact: true

Over-application example:

# Using reflection for a single known schema adds indirection with no benefit
def display_user(user) do
  User.__schema__(:fields)
  |> Enum.map(fn field -> "#{field}: #{Map.get(user, field)}" end)
  |> Enum.join(", ")
  # Dumps :password_hash, :reset_token, and every internal field
end

Better alternative:

# For a known schema, use explicit fields — safer and clearer intent
def display_user(user) do
  "#{user.name} <#{user.email}>"
end

Why: __schema__(:fields) returns all persisted fields including internal ones (inserted_at, updated_at, foreign keys). For display or serialization of a known schema, an explicit field list documents intent and prevents sensitive data from leaking. Use reflection for genuinely generic code where the alternative is a hardcoded list that diverges from the schema over time.


Decision Tree

  • If the app uses UUID primary keys across all schemas → create a base schema module with @primary_key {:id, :binary_id, autogenerate: true} and @foreign_key_type :binary_id
  • If a table has no single primary key (join table, composite key) → @primary_key false
  • If a field holds runtime-only data (password input, confirmation field, computed display value) → virtual: true
  • If you need changeset validation without a DB table (form objects, API request bodies) → embedded_schema
  • If timestamps need UTC compliance or microsecond precision → set @timestamps_opts [type: :utc_datetime_usec] in the base schema module
  • If a DB column name doesn't match Elixir conventions and can't be migrated → :source option on field/3
  • If a field holds sensitive data (passwords, tokens, PII) that must not appear in logs → redact: true
  • If writing generic code that works across schema modules without knowing their fields → __schema__/1 reflection