Debugging Ruby with binding.break — Ditch byebug, Keep the Flow
Ruby 3.1 shipped debug in the standard library, and it quietly made three gems unnecessary. No byebug, no pry-byebug, no version conflicts between them, and — the part that changes how you work — you can attach to a process that’s already running. If your debugging habit is still puts followed by a server restart, this is the upgrade worth ten minutes.
The Breakpoint
Example:
require "debug" # not needed in Rails, which requires it in the dev/test group
def build_invoice(account, period)
line_items = account.orders.in_period(period).flat_map(&:line_items)
binding.break
Invoice.new(account:, line_items:, total: line_items.sum(&:subtotal))
end
Execution stops, and you get a prompt with the surrounding source:
[8, 17] in ~/app/services/invoice_builder.rb
8| def build_invoice(account, period)
9| line_items = account.orders.in_period(period).flat_map(&:line_items)
=> 10| binding.break
11| Invoice.new(account:, line_items:, total: line_items.sum(&:subtotal))
12| end
(rdbg)
binding.b is the short alias. debugger also works, for anyone with the muscle memory.
Moving Around
| Command | Short | What it does |
|---|---|---|
step |
s |
Into the next method call |
next |
n |
Over the next line |
finish |
fin |
Run until the current method returns |
continue |
c |
Resume until the next breakpoint |
up / down |
Move through the call stack frames | |
backtrace |
bt |
Show the stack |
list |
l |
More source around the current line |
info |
i |
Local variables in the current frame |
quit |
q |
Exit the debugger |
up and down are the two people underuse. When you stop deep inside a gem, up walks back to your own frame with its locals intact — you can inspect the arguments your code passed without restarting anything.
Example:
(rdbg) bt
=>#0 Faraday::Connection#run_request at .../faraday/connection.rb:507
#1 PaymentGateway#charge at app/services/payment_gateway.rb:23
#2 Order#process! at app/models/order.rb:88
(rdbg) frame 1
(rdbg) info
amount_cents = 4250
idempotency_key = nil ← there it is
Setting Breakpoints Without Editing Files
The habit worth building. You don’t need to add binding.break and restart — set the breakpoint from the prompt.
Example:
(rdbg) break app/models/order.rb:88
(rdbg) break Order#process!
(rdbg) break Order.find_pending
# Conditional — only stop when it matters
(rdbg) break app/models/order.rb:88 if: order.total_cents > 100_000
# Run something and continue, without stopping
(rdbg) break Order#process! pre: p order.id do: continue
(rdbg) info breakpoints
(rdbg) delete 2
Conditional breakpoints are the difference between debugging a loop over ten thousand records and giving up. if: takes any Ruby expression evaluated in that scope.
Catch an exception where it’s raised
(rdbg) catch ActiveRecord::RecordInvalid
Now the debugger stops at the raise site — before any rescue swallows it — with the full local scope available. This one finds bugs that no amount of log reading will.
Watch a variable change
(rdbg) watch @status
Stops whenever the instance variable’s value changes, telling you which line did it. When something is being mutated and you can’t find where, this is the tool.
Attaching to a Running Process
The genuinely new capability. Start the process with a listener, attach from another terminal, and you don’t need the breakpoint in advance.
Example:
# Terminal 1 — start the server with a debug socket
bundle exec rdbg --open --nonstop -c -- bin/rails server
# Terminal 2 — attach whenever you want
bundle exec rdbg --attach
--nonstop means the app boots and serves traffic normally rather than halting at line one. When you attach, you get a prompt against the live process and can set breakpoints on the fly.
This works for background workers too, which is where it earns the most:
bundle exec rdbg --open --nonstop -c -- bundle exec sidekiq
Debugging a job that only misbehaves on production-shaped data, in a worker you can’t easily add a breakpoint to and restart, was genuinely hard before this.
Rails Specifics
Rails 7+ includes debug in the generated Gemfile:
group :development, :test do
gem "debug", platforms: %i[mri windows]
end
binding.break in a controller action halts the request and hands the prompt to whatever terminal is running bin/rails server. That works with a single-process server; with Puma in clustered mode it’s unreliable, which is one more reason to use rdbg --attach rather than inline breakpoints on anything concurrent.
For a breakpoint inside a spec:
bundle exec rspec spec/models/order_spec.rb:42
The debug gem plays fine with RSpec — no configuration needed.
Pro-Tip: Use
debug’s record-and-replay before reaching for step-by-step execution.record onat the prompt starts capturing, and afterwardsstep backwalks backwards through executed lines with the state as it was. The usual debugging failure is stepping one line too far past the interesting moment and having to restart the whole scenario to get back — with recording on, you just step backwards. It costs noticeable execution speed so don’t leave it on, but for the “I need to see what happened just before this” case it converts a ten-minute reproduce-and-restart cycle into two keystrokes. This alone is worth switching offbyebugfor; nothing in the old tooling does it.
Non-Interactive Debugging
Sometimes you want the information without the prompt — in CI, or in a place where stopping isn’t acceptable.
Example:
# Print and keep going
binding.break(do: "p order.attributes")
# Multiple commands
binding.break(do: "info; bt 5")
# Pre-command that runs before the prompt appears
binding.break(pre: "p line_items.size")
do: runs the commands and continues immediately, which turns a breakpoint into a targeted, scoped puts that you can also make conditional — and that you can set from the prompt on a running process without a deploy.
Configuring It
Example:
# ~/.rdbgrc
config set show_src_lines 15
config set no_color false
config set skip_path /gems/
# Aliases
alias bt backtrace
skip_path is the important one — without it, step walks you into activesupport internals and you spend your session pressing finish. Setting it to skip gem paths keeps stepping inside your own code where you want it.
Conclusion
debug replaces byebug and pry-byebug with something in the standard library that does more: conditional breakpoints set from the prompt, catch to stop at a raise site before any rescue, watch to find the line mutating a variable, attaching to a live process, and stepping backwards through recorded execution. Set skip_path so stepping stays in your code, learn up/down so a gem frame isn’t a dead end, and get in the habit of setting breakpoints from the prompt rather than editing a file and restarting. Keep Pry alongside it for exploration — ls, cd, show-source have no equivalent here — but for actual debugging, this is the tool.
FAQs
Q1: Do I still need byebug or pry-byebug?
No. debug supersedes both on Ruby 3.1+ and is maintained by the core team. Running byebug and debug together causes conflicts, so remove the old ones.
Q2: Does binding.break work in a threaded or forked server?
Poorly — the prompt goes to whichever process hits it, and with clustered Puma that’s unpredictable. Use rdbg --open --nonstop and attach instead.
Q3: Can I use it in production?
rdbg --attach over a Unix socket on a single instance, deliberately, with the socket permissions locked down — some teams do. Never expose the debug port over the network, and never leave the listener enabled by default.
Q4: How is this different from Pry?
debug is a debugger: breakpoints, stepping, stack navigation. Pry is a REPL with excellent object exploration. They overlap a little and complement each other a lot.
Q5: Why does step take me into gem code?
No skip_path configured. Add config set skip_path /gems/ to ~/.rdbgrc and stepping stays in your application.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀