Greetings, traveler!
Most iOS projects start with a few print() calls scattered around the codebase. It is the simplest way to see what is going on, and it works surprisingly well for very small problems. The trouble starts when those problems grow, state becomes more complex, and the app needs to be rebuilt again and again just to inspect a single value.
This text walks through several common debugging techniques in Swift and Xcode, and shows where each of them fits:
print()anddump(_:)- breakpoints and the LLDB console
v,po, andexprin LLDB- when one approach is preferable over another
The goal is not to ban any tool, but to understand their trade-offs.
print() debugging
The classic pattern:
func perform(value: Bool) {
print(">>>: perform(value: \(value)")
apiService.getPost { result in
switch result {
case .success(let post):
print(">>>: success:", post.title)
case .failure(let error):
print(">>>: error:", error)
}
}
}Advantages:
- trivial to add
- no extra concepts to learn
- works in any environment (simulator, device, tests)
Drawbacks become visible over time:
- noisy output, especially when several parts of the app log at the same time
- hard-coded messages that quickly go out of sync with the code
- code pollution; debug prints are easy to forget and ship to production
- every change requires a rebuild and a fresh run
- quite an expensive operation
print() is fine for quick, one-off checks, but it does not scale well to more complex flows.
dump(_:) for structured output
Swift includes another standard function that is useful when inspecting values: dump(_:).
dump(post)Compared to print(), dump(_:):
- walks the full structure of a value using reflection
- prints stored properties recursively
- includes type information and labels
For simple values, print(user) and dump(user) may look similar. For nested structs and classes, dump(_:) gives a much clearer view of what is stored inside.
A few notes:
dump(_:)respectsCustomDebugStringConvertibleandCustomReflectablewhere applicable.- It is more verbose and usually slower than
print(), so it is better suited for occasional deep inspections rather than high-frequency logging. - As with
print(), calls todump(_:)are still part of the source code and have to be cleaned up eventually.
When you need to understand the exact structure of a value, dump(_:) is often the better choice. When you just want to see a simple flag or an ID, print() is enough.
Examples:
struct Car {
let maxSpeed = 300
let horsepower = 350
}
let car = Car()
print(car)Car(maxSpeed: 300, horsepower: 350)Now, let’s change the code a bit and turn our object into a class.
class Car {
let maxSpeed = 300
let horsepower = 350
}
let car = Car()
print(car)After checking out the console, we will discover a disappointing output.
CarFinally, let’s use dump():
class Car {
let maxSpeed = 300
let horsepower = 350
}
let car = Car()
dump(car)Car
- maxSpeed: 300
- horsepower: 350LLDB tools
Once debugging moves beyond “log and rebuild”, it is usually time to rely on Xcode’s debugger and LLDB.
A simple pattern is to set a breakpoint on a line and use the debugger to inspect state when execution stops:
func perform() {
myStringVariable = "My String" // ⬅️ breakpoint
}At the breakpoint, you can:
- look at variables in the Variables View on the left
- use the LLDB console at the bottom for more control
This already avoids the rebuild cycle: the code stays clean, and you can evaluate state at runtime as many times as needed.
v vs po vs p: inspecting values in LLDB
Three LLDB commands are especially useful for inspecting variables:
v – read the value
v (short for frame variable) prints the value of a variable directly from the current stack frame. It is fast because it reads from memory without going through Swift’s formatting machinery.
Example:
(lldb) v myStringVariable
(String) myStringVariable = "Custom String"po – pretty printed output
(lldb) po myObject
Object(name: "My Object")
po evaluates a Swift expression and prints the result using print()-style semantics.
For many objects it will use CustomDebugStringConvertible or description, which often produces a nicer and more readable output than v.
p – full info in a temporary variable
Alongside v and po, the p command deserves separate attention. While po focuses on producing a readable description and v retrieves values directly from the current stack frame, p evaluates a Swift expression and stores the result in a temporary variable. This makes it a practical tool when you need to inspect the outcome of a computation rather than just the value of an existing symbol. The evaluated result can then be referenced in subsequent LLDB commands, allowing you to explore derived states, compare calculated values, or step through transformations without touching your source code. It is a convenient middle ground: more flexible than v, less intrusive than po, and well-suited for quick, iterative checks during debugging sessions.
A practical rule: use v for most lookups, switch to po when the summary from v is not sufficient, and reach for p when you need to evaluate an expression and inspect the result without committing to a full po execution.
expr: modifying state without rebuilding
One of the strongest features of LLDB is the ability to run arbitrary Swift expressions at a breakpoint using expr:
(lldb) expr self.message = "Custom Message"
(lldb) continueThis is particularly useful in a SwiftUI or Combine setup where UI depends heavily on state stored in an observable object.
For example, consider a view model:
@Observable
final class ViewModel {
var isFetchingData = false
var message: String?
private let apiService = PostAPIService()
func update() {
isFetchingData = true // ⬅️ breakpoint
apiService.getPost { [weak self] result in
switch result {
case .success(let post):
print(post.title) // ⬅️ breakpoint
case .failure(let error):
self?.message = error.localizedDescription
}
self?.isFetchingData = false // ⬅️ breakpoint
}
}
}Instead of adding temporary code to simulate different error states, you can:
- stop at the breakpoint
- change properties using
expr - let the app continue and see how the UI reacts
This avoids:
- editing source code just to test a scenario
- rebuilding the app after each small change
- leaving test hooks or flags in production code
For example:
(lldb) expr self.isFetchingData = false
(lldb) continueor:
(lldb) expr self.message = "Timeout"
(lldb) continueSkipping code paths
Sometimes the easiest way to bypass a problematic section of code is simply not to run it. LLDB allows that as well:
(lldb) expr returnThis exits the current function immediately and continues execution from the caller. It is a convenient way to short-circuit heavy operations during debugging.
When to rely on LLDB (v, po, p, expr)
- Debugging non-trivial flows: networking, authentication, state machines.
- Working with SwiftUI or Combine where UI is driven by observable properties.
- Investigating issues that only appear in specific environments or with specific data and are hard to reproduce by editing code.
LLDB keeps the source code clean and allows fast iteration over multiple scenarios.
Inspecting SwiftUI Rendering Triggers with Self._printChanges()
When debugging SwiftUI, understanding why a view re-renders often matters as much as inspecting state itself. SwiftUI includes a diagnostic utility called Self._printChanges() from LLDB, which exposes information about what triggered the most recent body recomputation. Invoking it helps you trace which pieces of state or bindings caused the update cycle. This is especially useful when performance issues surface or when unexpected view refreshes point to subtle state mutations. Although this API is not part of the public interface, it can be invaluable for gaining visibility into SwiftUI’s rendering decisions and for confirming that your state management behaves as intended.
Logging vs debugging
It is also worth separating debugging from logging. Even if you use LLDB heavily during development, you still need proper logging for production builds:
- Structured logs for networking and critical flows.
- Error reporting integrated with a crash or analytics system.
- Minimal, focused logging that is safe to leave in shipping code.
Debugging tools help you understand what happens while you are attached with Xcode. Logging helps you later, when you only have logs and crash reports.
Practical workflow suggestions
A pragmatic debugging workflow for a Swift/iOS project might look like this:
- Start with a breakpoint at the suspicious location instead of adding
print(). - Use
vto quickly inspect local variables. - Switch to
poonly when you need a nicely formatted representation. - Use
pwhen you want to evaluate an expression and capture the result without invoking the full behavior ofexpror relying onpo’s formatting. This is helpful for checking computed values, comparing transformations, or inspecting intermediate results while keeping the debugging surface minimal. - Use
exprto:
• Flip feature flags.
• Change observable properties.
• Swap mock data in and out.
• Skip code paths withexpr returnwhen convenient. - When debugging SwiftUI update cycles, call
Self._printChanges()to understand which pieces of state triggered a view refresh; this can surface hidden mutations or unexpected rendering paths. - If you need to understand the full structure of a value, temporarily call
dump()or useexpr dump(value)from LLDB. - Once the issue is understood, remove temporary breakpoints or disable them instead of leaving debug code in the source.
With this approach, print() remains a simple tool for trivial tasks, dump() serves as a quick way to see structure, and LLDB becomes the main instrument for serious debugging work.
Conclusion
Debugging is part of everyday development, so small efficiency gains compound over time. Moving more of the experimentation from source code into the debugger helps keep the codebase clean, reduces rebuild time, and makes it easier to reason about the system.
