Debugging Swift Code: From print() to LLDB


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() and dump(_:)
  • breakpoints and the LLDB console
  • v, po, and expr in 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(_:) respects CustomDebugStringConvertible and CustomReflectable where 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 to dump(_:) 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.

Car

Finally, let’s use dump():

class Car {
    let maxSpeed = 300
    let horsepower = 350
}

let car = Car()

dump(car)
Car 
  - maxSpeed: 300
  - horsepower: 350

LLDB 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) continue

This 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) continue

or:

(lldb) expr self.message = "Timeout"
(lldb) continue

Skipping 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 return

This 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 v to quickly inspect local variables.
  • Switch to po only when you need a nicely formatted representation.
  • Use p when you want to evaluate an expression and capture the result without invoking the full behavior of expr or relying on po’s formatting. This is helpful for checking computed values, comparing transformations, or inspecting intermediate results while keeping the debugging surface minimal.
  • Use expr to:
    • Flip feature flags.
    • Change observable properties.
    • Swap mock data in and out.
    • Skip code paths with expr return when 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 use expr 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.