Your toolkit for understanding what's actually happening
Primary source: Elixir Debugging guide, which covers all techniques in this lesson and more. Also: IEx docs.
Elixir gives you four debugging tools, from lightest to heaviest:
| Tool | What it does | Ruby equivalent |
|---|---|---|
IO.inspect/2 | Prints a value and returns it (pipe-friendly) | tap { |x| pp x } |
dbg/2 | Prints the expression, each pipe step, and the result | n/a |
IEx.pry/0 | Pauses execution and drops you into an interactive shell at that point | binding.pry |
| IEx helpers | Inspect processes, state, and the system at runtime | rails console |
The simplest tool. IO.inspect/2 prints a value to the terminal and returns it unchanged. That "returns it" part is crucial. It means you can drop it into the middle of a pipe chain without breaking anything:
def generate_slug(title) do
title
|> String.downcase()
|> IO.inspect(label: "after downcase") # prints, then passes through
|> String.replace(~r/[^a-z0-9\s]/, "")
|> IO.inspect(label: "after replace")
|> String.trim()
|> String.replace(~r/\s+/, "-")
end
Output in the terminal:
after downcase: "elixir for rubyists!"
after replace: "elixir for rubyists"
The label: option is important. Without it, you'll see values flying past with no idea which IO.inspect produced them.
puts x.inspect, but IO.inspect returns the value so it doesn't break chains. In Ruby you'd need .tap { |x| puts x } to achieve the same thing.
You can also use it inline for any value:
def handle_event("save", %{"talk" => attrs}, socket) do
IO.inspect(attrs, label: "save attrs")
scope = socket.assigns.current_scope |> IO.inspect(label: "scope")
# ...
end
dbg is IO.inspect's smarter sibling. Instead of printing a single value, it prints the expression itself, and for pipe chains, it shows the result at every step:
def generate_slug(title) do
title
|> String.downcase()
|> String.replace(~r/[^a-z0-9\s]/, "")
|> String.trim()
|> String.replace(~r/\s+/, "-")
|> dbg()
end
Output:
[lib/speechwave/talks.ex:141]
title = "Elixir for Rubyists!"
title |> String.downcase() #=> "elixir for rubyists!"
title |> String.downcase() |> String.replace(~r/[^a-z0-9\s]/, "") #=> "elixir for rubyists"
title |> String.downcase() |> String.replace(~r/[^a-z0-9\s]/, "") |> String.trim() #=> "elixir for rubyists"
title |> String.downcase() |> String.replace(~r/[^a-z0-9\s]/, "") |> String.trim()
|> String.replace(~r/\s+/, "-") #=> "elixir-for-rubyists"
One line of code, and you see the input value, every transformation, and the file/line where it happened. Like IO.inspect, it returns the value so it works inside pipes.
dbg() at the end of a pipe chain to see every step. Put it in the middle to see only the steps up to that point. It's a macro, so it can read the source code and print it. That's why you see the actual expressions, not just the values.
dbg works with any expression, not just pipes:
def handle_event("react", %{"emoji" => emoji}, socket) do
dbg(socket.assigns.session_id)
dbg(RateLimiter.allow?(socket.assigns.session_id))
# ...
end
Output:
[lib/speechwave_web/live/talk_live.ex:25]
socket.assigns.session_id #=> "phx-F5k2..."
[lib/speechwave_web/live/talk_live.ex:26]
RateLimiter.allow?(socket.assigns.session_id) #=> true
When you need to explore, not just print. IEx.pry pauses execution and drops you into a live IEx session where you can inspect any variable in scope.
def handle_event("save", %{"talk" => attrs}, socket) do
require IEx; IEx.pry()
scope = socket.assigns.current_scope
# ...
end
The require IEx is needed because pry is a macro. You can put both on one line for convenience.
iex -S mix phx.server
This runs the Phoenix server inside an IEx session. When code hits IEx.pry(), execution pauses and your terminal becomes an interactive shell at that point:
Request to pry #PID<0.750.0> at SpeechwaveWeb.DashboardLive.handle_event/3
(lib/speechwave_web/live/dashboard_live.ex:44)
42: def handle_event("save", %{"talk" => attrs}, socket) do
43: require IEx; IEx.pry()
44: scope = socket.assigns.current_scope
Allow? [Yn]
Type Y (or just Enter), and you can explore:
iex> attrs
%{"title" => "My New Talk", "slug" => "my-new-talk"}
iex> socket.assigns.current_scope
%Speechwave.Accounts.Scope{user: %Speechwave.Accounts.User{...}}
iex> socket.assigns.talks |> length()
3
Type continue to resume execution, or respawn to give up and restart the shell.
binding.pry (Pry) or debugger (Ruby 3.1+). Same workflow: add the line, hit the code path, explore variables.
IEx.pry only works when the app is started with iex -S mix phx.server, not plain mix phx.server. Without IEx, the pry is silently skipped. Also: a pry in a LiveView callback will freeze that LiveView process while you're exploring. The browser tab will hang until you continue.
Even without pry, iex -S mix gives you a live console connected to your application. You can call any function, query the database, and inspect processes.
iex -S mix
# Query the database directly
alias Speechwave.{Repo, Talks, Accounts}
alias Speechwave.Talks.Talk
import Ecto.Query
# List all talks
Repo.all(Talk)
# Find a specific talk
Repo.get_by(Talk, slug: "my-talk")
# Run a context function
user = Repo.get_by(Accounts.User, email: "speaker@example.com")
scope = Accounts.Scope.for_user(user)
Talks.list_talks(scope)
# Count reactions for a session
Speechwave.Reactions.count_reactions(1)
# See all running named processes
Process.registered()
# Check if RateLimiter is running
Process.whereis(Speechwave.RateLimiter)
# Look at the ETS rate limiter table
:ets.tab2list(:rate_limiter)
# See the auth throttle state
:ets.tab2list(:auth_throttle_email)
:ets.tab2list(:auth_throttle_ip)
# Get the GenServer's internal state
:sys.get_state(Speechwave.RateLimiter)
# After editing a file, recompile it live:
recompile()
# The running app picks up the changes immediately.
# No need to restart the server.
recompile() in IEx is incredibly powerful. Edit a function, run recompile(), and the running application uses the new code immediately. No server restart, no lost state. This is one of the BEAM's superpowers: hot code reloading.
| Situation | Tool | Why |
|---|---|---|
| Quick check: "what value is this?" | IO.inspect(val, label: "x") | Fastest to add and remove |
| Pipe chain: "where does it go wrong?" | |> dbg() | Shows every step automatically |
| Need to explore: "what's on this socket?" | require IEx; IEx.pry() | Full interactive access to all variables |
| General exploration or DB queries | iex -S mix | Live console, no code changes needed |
| Check ETS / process state at runtime | :ets.tab2list/1, :sys.get_state/1 | Inspect running GenServer state without pry |
IO.inspect, dbg, and IEx.pry calls before committing. Speechwave's mix precommit alias runs compile --warnings-as-errors, which will catch leftover dbg calls (the compiler warns about them). But IO.inspect won't trigger a warning, so you need to catch those yourself.
This lesson is best learned hands-on. Try each of these exercises:
Add |> dbg() to the end of generate_slug/1 in lib/speechwave/talks.ex. Start the server with iex -S mix phx.server, go to the dashboard, and create a talk. Watch the terminal output. Remove the dbg when done.
Start iex -S mix and run:
alias Speechwave.{Repo, Talks}
import Ecto.Query
Repo.all(from t in Talks.Talk, select: {t.title, t.slug})
Then check the rate limiter state:
:ets.tab2list(:rate_limiter)
:ets.info(:rate_limiter)
Add require IEx; IEx.pry() at the top of the "show_qr" handler in dashboard_live.ex. Start with iex -S mix phx.server, click a talk in the dashboard, and explore the socket assigns from the pry session. Type continue to resume. Remove the pry when done.