MANDATORY systematic debugging - trace to root cause before proposing fixes. NO random changes, NO symptom fixes, NO "try this". Use when debugging ANY error or issue.
NEVER fix symptoms. ALWAYS trace to the root cause.
No guessing. No "try this". No random changes. No symptom fixes.
TRACE. UNDERSTAND. FIX. VERIFY.
You are NEVER allowed to:
Propose random fixes
Fix where error appears
Make multiple changes at once
Skip understanding
Accept "works on my machine"
Objective: Get consistent, repeatable reproduction.
# Required steps:
1. Identify exact steps to trigger the issue
2. Run those steps
3. Confirm issue appears
4. Document steps precisely
5. Verify it reproduces every time
Output required:
## Reproduction Steps
1. Run `mix test test/my_app/accounts_test.exs:42`
2. Error appears: "undefined function User.changeset/2"
3. Reproduces 100% of the time
4. Environment: Elixir 1.15.7, OTP 26
CHECKPOINT: Cannot proceed to Phase 2 until you have consistent reproduction.
Objective: Follow the error back to its origin.
# Tracing questions:
1. What function fails?
2. What called that function?
3. What called THAT function?
4. Where did the bad data/state originate?
5. What's the first point where things went wrong?
Tracing tools:
# 1. Add IO.inspect to see data flow
def create_user(attrs) do
attrs
|> IO.inspect(label: "Input attrs")
|> User.changeset(%User{})
|> IO.inspect(label: "Changeset")
|> Repo.insert()
end
# 2. Use IEx.pry for interactive debugging
def create_user(attrs) do
require IEx; IEx.pry()
# Execution pauses here
User.changeset(%User{}, attrs)
|> Repo.insert()
end
# 3. Check the stack trace completely
** (UndefinedFunctionError) function User.changeset/2 is undefined
(my_app 0.1.0) lib/my_app/accounts/user.ex:42: User.changeset/2
(my_app 0.1.0) lib/my_app/accounts.ex:15: MyApp.Accounts.create_user/1
test/my_app/accounts_test.exs:25: (test)
Output required:
## Root Cause Trace
Error appears: lib/my_app/accounts.ex:15
Called from: test/my_app/accounts_test.exs:25
Root cause: lib/my_app/accounts/user.ex:42
Reason: User.changeset/2 is not defined (should be User.changeset/1)
CHECKPOINT: Cannot proceed to Phase 3 until root cause is identified.
Objective: Fix the root cause, not the symptom.
# BAD: Fix where error appears
# In accounts.ex
def create_user(attrs) do
# Catch the error and work around it
try do
User.changeset(%User{}, attrs)
rescue
UndefinedFunctionError ->
User.new_changeset(%User{}, attrs) # Symptom fix!
end
end
# GOOD: Fix the root cause
# In user.ex - fix the actual function definition
defmodule MyApp.Accounts.User do
def changeset(user \\ %User{}, attrs) do # ← Fixed arity
user
|> cast(attrs, [:name, :email])
|> validate_required([:name, :email])
end
end
Output required:
## Fix Applied
Location: lib/my_app/accounts/user.ex:42
Change: Modified `changeset/2` to `changeset/1` with default parameter
Reason: Function was being called with 2 args but only defined with 1
CHECKPOINT: Fix must address root cause, not symptom.
Objective: Prove the fix works and didn't break anything else.
# Required verification:
1. Run the failing test/command
2. Confirm it now passes
3. Run full test suite
4. Confirm no regressions
5. Document verification
Output required:
## Verification
$ mix test test/my_app/accounts_test.exs:42
.
1 test, 0 failures ✓
$ mix test
..........
10 tests, 0 failures ✓
Root cause fixed, no regressions.
CHECKPOINT: Cannot claim complete until verified.
Error:
lib/my_app/billing.ex:42:pattern_can_never_match
Pattern {:ok, amount} can never match type {:error, :invalid}
WRONG approach (symptom fix):
# Just add to dialyzer.ignore
RIGHT approach (root cause):
Phase 1 - Reproduce:
$ mix dialyzer
# Error appears consistently
Phase 2 - Trace:
# lib/my_app/billing.ex:42
def process_payment(user_id, amount) do
case validate_amount(amount) do
{:ok, amount} -> charge(user_id, amount) # Line 42
{:error, reason} -> {:error, reason}
end
end
# Trace back to validate_amount/1
def validate_amount(amount) when amount > 0 do
{:ok, amount}
end
def validate_amount(_amount) do
{:error, :invalid} # This is the only return value!
end
Root cause: validate_amount/1 ALWAYS returns {:error, :invalid} for non-positive amounts, so the {:ok, amount} pattern can never match for the error case.
Phase 3 - Fix:
# Fix the logic - validate_amount should return {:ok, amount} for valid amounts
def validate_amount(amount) when amount > 0 do
{:ok, amount}
end
def validate_amount(_amount) do
{:error, :invalid_amount}
end
# Or fix the pattern match to handle the actual return type
def process_payment(user_id, amount) do
case validate_amount(amount) do
{:ok, valid_amount} -> charge(user_id, valid_amount)
{:error, :invalid_amount} -> {:error, :invalid_amount}
end
end
Phase 4 - Verify:
$ mix dialyzer
Total errors: 0, Skipped: 0
done (passed successfully)
Error:
test create_user with valid attrs (MyApp.AccountsTest)
** (KeyError) key :email not found
WRONG approach (symptom fix):
# Just add a default email
test "create_user with valid attrs" do
attrs = Map.put(%{name: "Alice"}, :email, "[email protected]")
# ...
end
RIGHT approach (root cause):
Phase 1 - Reproduce:
$ mix test test/my_app/accounts_test.exs:42
** (KeyError) key :email not found
Phase 2 - Trace:
# Test code
test "create_user with valid attrs" do
attrs = %{name: "Alice"} # Missing :email
assert {:ok, user} = Accounts.create_user(attrs) # Fails here
end
# Trace to create_user
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
# Trace to changeset
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email])
|> validate_required([:name, :email]) # Requires :email!
|> validate_format(:email, ~r/@/) # Accesses attrs.email
end
Root cause: Test fixture doesn't include required :email field. The schema validation requires :email, but test attrs don't provide it.
Phase 3 - Fix:
# Fix the test to provide required data
test "create_user with valid attrs" do
attrs = %{name: "Alice", email: "[email protected]"}
assert {:ok, user} = Accounts.create_user(attrs)
assert user.name == "Alice"
assert user.email == "[email protected]"
end
# OR if email shouldn't be required, fix the schema
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email])
|> validate_required([:name]) # Email is optional
end
Phase 4 - Verify:
$ mix test test/my_app/accounts_test.exs:42
.
1 test, 0 failures
Symptom:
GET /users - 342ms (slow!)
WRONG approach (symptom fix):
# Just add a cache
def list_users do
Cachex.get_or_store(:users, fn ->
Repo.all(User)
end)
end
RIGHT approach (root cause):
Phase 1 - Reproduce:
# Enable query logging
config :logger, level: :debug
$ curl localhost:4000/users
# Logs show:
SELECT * FROM users
SELECT * FROM posts WHERE user_id = 1
SELECT * FROM posts WHERE user_id = 2
SELECT * FROM posts WHERE user_id = 3
# ... 100 queries total for 100 users
Phase 2 - Trace:
# Controller
def index(conn, _params) do
users = Accounts.list_users()
render(conn, "index.html", users: users)
end
# View template
<%= for user <- @users do %>
<div>
<%= user.name %>
Posts: <%= length(user.posts) %> # ← N+1 trigger!
</div>
<% end %>
# Context
def list_users do
Repo.all(User) # Doesn't preload posts
end
Root cause: View accesses user.posts which triggers a separate query for each user. The context doesn't preload the association.
Phase 3 - Fix:
# Fix: Preload in the context
def list_users do
User
|> Repo.all()
|> Repo.preload(:posts)
end
Phase 4 - Verify:
$ curl localhost:4000/users
# Logs show:
SELECT * FROM users
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ..., 100)
# 2 queries instead of 101!
# Response time: 342ms → 45ms
def create_user(attrs) do
require IEx; IEx.pry()
# Execution pauses, you can inspect:
# > attrs
# > User.__struct__()
# > continue to proceed
User.changeset(%User{}, attrs)
end
def create_user(attrs) do
attrs
|> IO.inspect(label: "Raw attrs")
|> Map.put(:inserted_at, DateTime.utc_now())
|> IO.inspect(label: "With timestamp")
|> User.changeset(%User{})
|> IO.inspect(label: "Changeset")
end
require Logger
def create_user(attrs) do
Logger.debug("Creating user with attrs: #{inspect(attrs)}")
case User.changeset(%User{}, attrs) |> Repo.insert() do
{:ok, user} ->
Logger.info("User created: #{user.id}")
{:ok, user}
{:error, changeset} ->
Logger.error("Failed to create user: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
# Start Observer GUI
iex -S mix
iex> :observer.start()
# Shows:
# - Process tree
# - Memory usage
# - Message queues
# - ETS tables
# Find slow processes
:recon.proc_window(:memory, 3, 1000)
# Trace function calls
:recon_trace.calls({MyApp.Accounts, :create_user, :return_trace}, 10)
WRONG. Random changes waste time. Trace to root cause first.
WRONG. 90% sure = 10% broken. Get to 100% by tracing.
WRONG. Debug in development where you have full tools and can break things.
WRONG. Error messages are precise. Read them completely and carefully.
WRONG. "Probably" means you haven't traced. Race conditions are reproducible with the right tools.
WRONG. Change ONE thing, verify, repeat. Multiple changes = confusion.
❌ "Try this" ❌ "Maybe this will work" ❌ "Let's see if" ❌ "Could you try" ❌ "I think the issue is" ❌ "Just restart it" ❌ "Clear the cache" ❌ "It works on my machine" ❌ "I'm not sure why, but" ❌ "This is a Heisenbug"
Instead: Trace, understand, fix with certainty.
Before proposing any fix:
If you can't check ALL boxes, keep tracing.
No fix without understanding.
No changes without tracing.
Root cause only. Always.
"The error appears where the problem is detected, not where it originates."
"Symptoms are visible. Root causes must be traced."
"Random fixes might work by accident. Understanding works on purpose."
TRACE. UNDERSTAND. FIX. VERIFY.