Blog

Debugging Apex with debug logs — the workflow, the limits, and the way out

On most platforms, "how do I debug this?" has a one-word answer: breakpoints. On Salesforce, the standard answer is a log file — generated remotely, capped in size, gated by trace flags that expire. Here's how to work that system well, and what it looks like when you don't have to.

Short version

Debug-log debugging is print-statement debugging with extra steps: set a trace flag, reproduce, fetch the log, search it, add a System.debug where the answer wasn't, deploy, reproduce again. sf apex tail log streams logs live and is the best version of this workflow. But the structural limits — 20 MB truncation, expiring trace flags, one round-trip per new question — don't optimize away. A local runtime turns the same investigation into breakpoints and variable inspection.

How debug logs actually get created

An org writes a debug log only when a trace flag is active for the running user. The trace flag points at a debug level — a bundle of per-category verbosity settings (Apex code, database, callouts, validation…) from ERROR down to FINEST. No trace flag, no log: the bug you just reproduced without one produced nothing to read.

Trace flags expire — by design, since verbose logging is expensive — which is why the second-most-common debugging failure after "no repro" is "reproduced it perfectly, trace flag had lapsed." The sf CLI's answer is tail, which sets up the trace flag for you and streams:

bash
# Stream logs live, with color, applying a debug level
sf apex tail log --color --debug-level SFDC_DevConsole

# Or work with stored logs
sf apex list log
sf apex get log --number 1          # most recent log
sf apex get log --log-id 07LXXXXXXXXXXXX

Reading one: the skill nobody put on the job description

A raw log at FINEST is thousands of lines of event records — CODE_UNIT_STARTED, SOQL_EXECUTE_BEGIN, VARIABLE_ASSIGNMENT — with your actual clue buried somewhere in the middle. Working developers converge on the same techniques:

  • Search for USER_DEBUG first. Your own System.debug lines are the only entries you chose to emit.
  • Then EXCEPTION_THROWN and FATAL_ERROR. The failure and its stack, if the log kept them.
  • Tune the debug level down, not up. FINEST on the database category will bury you and hit the size cap faster. Start at DEBUG for Apex and INFO elsewhere; raise one category at a time.

The size cap is the trap: logs are limited to 20 MB, and past it the platform drops detail or truncates. On a busy trigger stack under bulk load, the moment you're debugging is often the moment the log is too big to hold — the interesting part gets cut precisely because it was interesting.

The loop you're actually in

Notice the shape of a debug-log investigation. Every new question — "what's in that map right here?" — costs a full cycle: add a System.debug, deploy, reproduce, fetch, search. Five questions, five round-trips, each a few minutes. This is print-statement debugging, the technique the rest of the industry treats as a fallback, institutionalized as the primary interface.

Salesforce's own answers acknowledge the gap. The Apex Replay Debugger replays a debug log as if stepping through it — genuinely useful, but it inherits every log limitation: truncated log, truncated replay, and heap inspection limited to what the log recorded (a detailed comparison). The Apex Interactive Debugger sets real breakpoints in a sandbox, but it's a paid add-on with session limits, and most developers have never seen it running.

What debugging looks like without the org in the way

All of the above follows from one premise: the code runs somewhere you can't attach to. Nimbus drops that premise — your Apex executes locally, so it can host an actual debugger, wired into VS Code and IntelliJ through the standard debug protocol:

  • Breakpoints in Apex — in classes, triggers, and test methods. Execution stops; you look around.
  • Variable inspection — the real contents of that map, expandable in the editor, not a String.valueOf you remembered to add.
  • Step in / over / out through your code, including into the trigger stack a DML statement fires.
  • No trace flags, no expiry, no size cap — there's no log mediating between you and the running program.
bash
# Run a test under the debugger, hit your breakpoint
nimbus test "InvoiceServiceTest.testProration" --debug

The five-questions investigation becomes one run: hit the breakpoint, answer all five by looking. System.debug output still prints — instantly, in your terminal — for the cases where a print is genuinely the right tool. And for the bugs that only exist in a real org (org data, managed packages, platform config), the log workflow above remains the tool; the point is to stop paying its cost for the bugs that are just your code.

Checklist

  • No trace flag, no logsf apex tail log handles the flag and streams live.
  • Search USER_DEBUG and EXCEPTION_THROWN first; read sequentially never.
  • Tune levels per category, downward. FINEST-everything mostly buys truncation.
  • Count your round-trips. Each new question costing a deploy is the real expense.
  • Use a real debugger for code-shaped bugs — save the log workflow for org-shaped ones.

Breakpoints in your Apex, today

Nimbus runs your Apex locally and ships a full debugger — breakpoints, stepping, variable inspection — in VS Code and IntelliJ. No trace flags anywhere.