Why do SLF4J log calls use a message with `{}` placeholders and separate arguments instead of building the message with string concatenation, and when do you still need an `isDebugEnabled()` guard?
answer
- pattern + args, substitute only if enabled
- concatenation evaluates eagerly
- guard only for expensive argument computation
- trailing Throwable prints the stack trace
- pattern is the stable event identity
basics
~20 sPlaceholders defer formatting: if the level is disabled, the arguments are never converted to strings and no message is built. Concatenation pays that cost on every call. You only need an enabled-check when computing an argument is itself expensive.
solid answer
~50 s`log.debug("user {} bought {} items", userId, count)` passes the pattern and the arguments separately. The backend first asks whether DEBUG is enabled for that logger; if not, it returns immediately and never calls `toString()` on the arguments and never allocates the joined string. With `log.debug("user " + userId + ...)` the concatenation happens before the call, so a disabled level still costs allocation and string conversion — the classic reason people wrapped calls in `if (log.isDebugEnabled())`. With placeholders, that guard is redundant for the formatting itself. It is still worth it when producing an argument is expensive, for example serialising a large object or running a query, because the argument expression is evaluated at the call site regardless. A trailing `Throwable` argument is special-cased: it is not consumed by a placeholder and its stack trace is printed. Escape a literal brace pair with a backslash, and note that a surplus of arguments is ignored while a shortage leaves the placeholder text in place.
code
text · 4 lineslog.debug("cart " + cart) -> cart.toString() runs, string built, then discarded
log.debug("cart {}", cart) -> level checked, return; toString() never runs
log.debug("cart {}", expensive()) -> expensive() still runs; guard this one
log.error("load {} failed", id, e) -> {} takes id, e is the throwable, stack trace printedgo deeper
Say that the message is only assembled if the level is enabled, so placeholders avoid wasted string building, and put the exception last.
Add that arguments are still evaluated eagerly, that overloads exist to avoid varargs allocation, and explain the trailing-throwable rule and escaping.
Discuss the pattern as a stable event identity for aggregation, the mutable-argument hazard with asynchronous appenders, and when a guard genuinely pays off.
Treat it as a convention question: enforce placeholders and structured arguments by lint rule so the log stream stays queryable and hot paths stay allocation-free by default.
## The mechanism An SLF4J call such as `logger.debug("order {} for customer {} totalling {}", orderId, customerId, total)` does not build a message. It hands the backend a *format string* plus the argument references. Only after the backend decides the event will actually be handled does it run the substitution, walking the pattern once and replacing each `{}` with the corresponding argument's string form. If the level is disabled for that logger, the method returns almost immediately, and the arguments are never converted. Compare with concatenation. `logger.debug("order " + orderId + " for customer " + customerId)` evaluates the whole expression *before* the call, because arguments in Java are evaluated eagerly. Every `toString()` runs, a string is built, and then the disabled logger throws it away. On a hot path executed millions of times, that is real allocation pressure for output nobody sees. This is the entire reason the older codebase idiom `if (logger.isDebugEnabled()) { logger.debug("..." + x); }` exists. ## What the guard is still for Placeholders remove the *formatting* cost, not the *argument production* cost. The expression that produces each argument still runs at the call site. So: - `log.debug("state {}", counter)` — no guard needed; passing a reference is free. - `log.debug("snapshot {}", buildFullDiagnosticReport())` — the report is built whether or not DEBUG is on. Here an `isDebugEnabled()` guard is justified, or in SLF4J 2.x the fluent API's supplier-style argument defers it. A subtler cost is autoboxing: a primitive argument is boxed to pass through the varargs/Object parameters. The API provides one- and two-argument overloads specifically so the common cases avoid allocating an `Object[]`; three or more arguments use varargs and do allocate an array. This matters only on genuinely hot paths, but it explains the overload set. ## Placeholder rules that trip people up - **Positional, not named.** `{}` has no index and no name; arguments are consumed left to right. Reordering the arguments silently reorders the message. - **Mismatched counts do not throw.** More placeholders than arguments leaves the surplus `{}` literally in the output; more arguments than placeholders means the extras are ignored (except a trailing throwable, below). Neither case fails the build or the call. - **Escaping.** A literal brace pair is written by escaping the opening brace with a backslash; the escape itself can be escaped when you want a backslash before a real placeholder. This matters when logging JSON-ish text. - **Throwable last.** If the final argument is a `Throwable` and it is not matched by a placeholder, it is treated as the exception for the event and its stack trace is rendered. This is why `log.error("failed to load {}", id, ex)` is correct while `log.error("failed to load " + id, ex.getMessage())` loses the stack trace entirely. Losing stack traces this way is one of the most common real logging bugs. - **`toString()` runs on the logging thread, later.** With an asynchronous appender the conversion may happen after your method returned; if you pass a mutable object, its state may have changed by then. Pass immutable values or an already-computed snapshot for anything that mutates. ## Why this also helps beyond performance The pattern is a stable identity for the event. Tooling that groups or counts log events keys on the unrendered pattern, so "order {} failed" is one event type with a varying parameter rather than a million distinct strings. Structured backends can also emit the arguments as separate fields rather than as an already-flattened sentence, which is the difference between a searchable field and a substring match. SLF4J 2.x extends this with explicit key-value pairs on the fluent API for exactly that reason. ## Practical rule Always use placeholders. Add an enabled-check only when an argument expression is expensive to compute, and put the exception last without a placeholder.
- You want the exception's stack trace in an error message that also contains two parameters. How do you write the call?Put two placeholders in the pattern for the two parameters, then pass the throwable as the final argument with no placeholder of its own: the pattern has N placeholders and the call has N+1 arguments. SLF4J recognises the unmatched trailing throwable and hands it to the backend as the event's exception, so the layout prints the full stack trace. Putting the exception inside a placeholder, or passing only its message, reduces it to a one-line string and loses the trace.
- Does using placeholders make a disabled log call completely free?Nearly, but not entirely. The method call still happens and the backend still performs a level check, which is a cheap field comparison against the effective level. What remains genuinely non-free is anything you computed to produce the arguments, plus autoboxing of primitives and, from three arguments upward, the varargs array. On a very hot loop those residuals can justify an explicit enabled-check; everywhere else they are noise.
- Why can a mutable object logged with a placeholder show unexpected values in the output?Because the argument is passed by reference and rendered later — at the point the backend formats the event, which with an asynchronous appender can be after your method has returned and mutated the object. The log then shows the object's state at format time rather than at call time. Log an immutable value, a copy, or a pre-rendered summary when the object is being mutated concurrently.
saying these in an interview costs you the question
- Keeping isDebugEnabled() wrappers around plain placeholder calls and calling it an optimisation.
- Passing the exception's message instead of the exception, which silently discards the stack trace.
- Assuming a placeholder/argument count mismatch will throw or be caught at build time.
- Believing {} placeholders are named or indexed and can be reordered independently of the arguments.
- Claiming placeholders make the call free, ignoring the cost of computing the arguments.