← docs.speechwave.live

Lesson 8: debugging with IEx and dbg

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.

The tools

Elixir gives you four debugging tools, from lightest to heaviest:

ToolWhat it doesRuby equivalent
IO.inspect/2Prints a value and returns it (pipe-friendly)tap { |x| pp x }
dbg/2Prints the expression, each pipe step, and the resultn/a
IEx.pry/0Pauses execution and drops you into an interactive shell at that pointbinding.pry
IEx helpersInspect processes, state, and the system at runtimerails console

IO.inspect: the print debugger

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.

Ruby: Like 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.

Inspecting inside non-pipe expressions

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: the smart print debugger

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.

Key insight: Put 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 outside of pipes

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

IEx.pry: interactive breakpoints

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.

Step 1: add the pry

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.

Step 2: start the server with IEx

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.

Ruby: Exactly like binding.pry (Pry) or debugger (Ruby 3.1+). Same workflow: add the line, hit the code path, explore variables.
Important: 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.

IEx as a runtime console

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.

Database exploration

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)

Inspecting processes

# 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)

Recompiling without restarting

# After editing a file, recompile it live:
recompile()

# The running app picks up the changes immediately.
# No need to restart the server.
Key insight: 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.

Choosing the right tool

SituationToolWhy
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 queriesiex -S mixLive console, no code changes needed
Check ETS / process state at runtime:ets.tab2list/1, :sys.get_state/1Inspect running GenServer state without pry
Remember to clean up! Remove 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.

Try it

This lesson is best learned hands-on. Try each of these exercises:

Exercise 1: dbg a pipe chain

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.

Exercise 2: explore with IEx

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)

Exercise 3: pry into a LiveView

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.