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

894 lines
30 KiB
Markdown

# Ecto Schema Patterns
Patterns extracted from `lib/ecto/schema.ex` in the Ecto source.
## Contents
1. [Base Schema Module — App-Wide Schema Defaults](#1-base-schema-module--app-wide-schema-defaults)
2. [`@primary_key false` — Composite or No Primary Key](#2-primary_key-false--composite-or-no-primary-key)
3. [Virtual Fields — In-Memory-Only Data](#3-virtual-fields--in-memory-only-data)
4. [`embedded_schema/1` — Schemaless Validation Structs](#4-embedded_schema1--schemaless-validation-structs)
5. [`@timestamps_opts` — Consistent Timestamp Types](#5-timestamps_opts--consistent-timestamp-types)
6. [Field `:source` Option — Column Name Mapping](#6-field-source-option--column-name-mapping)
7. [`redact: true` — Protecting Sensitive Fields](#7-redact-true--protecting-sensitive-fields)
8. [`__schema__/1` Reflection — Runtime Schema Introspection](#8-__schema__1-reflection--runtime-schema-introspection)
---
## 1. Base Schema Module — App-Wide Schema Defaults
**Source:** [lib/ecto/schema.ex#L194](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/schema.ex#L194)
```elixir
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:
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/schema.ex#L1525)
```elixir
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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/schema.ex#L332)
```elixir
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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/schema.ex#L110)
```elixir
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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# 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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/schema.ex#L170)
```elixir
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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/schema.ex)
```elixir
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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/schema.ex#L128)
```elixir
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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/schema.ex#L450)
```elixir
# 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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
# 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
<!-- PATTERN_COMPLETE -->