Set up Groovy logging with @Slf4j and SLF4J. 10 tested examples covering log levels, Logback config, GString logging, and best practices on Groovy 5.x.
“The art of logging is knowing what to record and what to ignore. Too little and you’re blind; too much and you’re drowning.”
Robert C. Martin, Clean Code
Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate | Reading Time: 16 minutes
Logging is one of those things that separates production-ready code from weekend scripts. If you’ve been using println to debug your Groovy applications, it’s time to level up. Groovy logging with the @Slf4j annotation (documented in the official Groovy metaprogramming guide) gives you structured, configurable, and performant logging with almost zero boilerplate – Groovy’s AST transformations inject the logger for you at compile time.
In this guide, we’ll walk through 10 tested examples that cover everything from basic @Slf4j usage to MDC (Mapped Diagnostic Context), custom log formats, and alternative logging annotations. Together, they give you a solid foundation for adding proper logging to any Groovy project.
If you’re not familiar with Groovy’s AST transformations, take a quick look at our Groovy AST Transformations guide first – @Slf4j is one of the most practical examples of this feature. And if you’re still using println everywhere, our Groovy print vs println guide explains why that approach doesn’t scale.
Table of Contents
Why Logging Matters in Groovy
Before we start with the examples, let’s talk about why logging matters and why println is not a substitute for a proper logging framework.
- Log levels – You can separate trace-level noise from critical errors. In production, you turn off debug messages without touching code.
- Output destinations – Logs can go to files, consoles, databases, or remote aggregation services like ELK or Splunk.
printlnonly goes to stdout. - Performance – Logging frameworks use lazy evaluation and parameterized messages. String concatenation in
printlnhappens even when no one is reading the output. - Context – MDC (Mapped Diagnostic Context) lets you tag log messages with request IDs, user sessions, or transaction identifiers without passing them through every method.
- Rotation and retention – Logback and Log4j handle file rotation, compression, and retention policies. You don’t want an unmanaged log file eating your entire disk.
- Structured output – JSON-formatted logs are machine-parseable and work directly with modern observability stacks.
In short, println is fine for learning and quick scripts. For anything that runs in production or has more than one developer, you need a real logging framework.
SLF4J and Groovy – How It Works
SLF4J (Simple Logging Facade for Java) is a logging abstraction layer. It defines the API – the Logger interface with methods like info(), debug(), and error() – but does not implement logging itself. You pair SLF4J with a binding (the actual implementation), and the most popular binding is Logback.
Here’s the architecture:
- SLF4J API (
slf4j-api) – The facade. Your code calls this. - Logback Classic (
logback-classic) – The implementation. It does the actual writing to files, consoles, etc. - Logback Core (
logback-core) – The engine underneath Logback Classic.
Groovy makes this even easier with the @Slf4j annotation from groovy.util.logging. This is an AST transformation that automatically injects a private static final Logger log field into your class at compile time. You never have to write the boilerplate LoggerFactory.getLogger() call yourself.
Dependencies you need (in a Gradle project):
build.gradle – Logging Dependencies
dependencies {
implementation 'org.apache.groovy:groovy:5.0.0'
implementation 'org.slf4j:slf4j-api:2.0.12'
implementation 'ch.qos.logback:logback-classic:1.5.6'
}If you’re running Groovy scripts (not a full Gradle project), you can use @Grab to pull in the dependencies at runtime – we’ll cover that in Example 10.
10 Tested Examples of Groovy Logging
Each example below is self-contained and tested on Groovy 5.x with Java 17. You can run them as Groovy scripts or within a Gradle project.
Example 1: Basic Logging with @Slf4j Annotation
The simplest way to add logging to a Groovy class. The @Slf4j annotation injects a log field automatically – no manual logger creation needed.
Example 1 – Basic @Slf4j Logging
import groovy.util.logging.Slf4j
@Slf4j
class UserService {
String findUser(String username) {
log.info('Looking up user: {}', username)
// Simulate a lookup
def user = "User(${username})"
log.info('Found user: {}', user)
return user
}
static void main(String[] args) {
def service = new UserService()
service.findUser('alice')
}
}Output
12:34:56.789 [main] INFO UserService - Looking up user: alice 12:34:56.790 [main] INFO UserService - Found user: User(alice)
What’s happening: The @Slf4j annotation triggers a compile-time AST transformation that adds private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(UserService) to your class. The logger name matches the class name by default. The {} placeholders are SLF4J’s parameterized message syntax – more on that in Example 3.
Example 2: Log Levels – trace, debug, info, warn, error
SLF4J defines five log levels in order of severity. Understanding when to use each level is critical for keeping your logs useful without drowning in noise.
Example 2 – Log Levels Demonstration
import groovy.util.logging.Slf4j
@Slf4j
class LogLevelDemo {
void demonstrateLevels() {
log.trace('TRACE: Very fine-grained - loop iterations, variable states')
log.debug('DEBUG: Diagnostic info - method entry, parameter values')
log.info('INFO: Business events - user logged in, order placed')
log.warn('WARN: Something unexpected - slow query, deprecated API call')
log.error('ERROR: Something broke - exception caught, operation failed')
}
void processOrder(String orderId) {
log.debug('Entering processOrder with orderId={}', orderId)
try {
log.info('Processing order {}', orderId)
if (orderId == null) {
log.warn('Null orderId received - this should not happen')
return
}
// Simulate processing
log.trace('Order {} passed validation step 1', orderId)
log.trace('Order {} passed validation step 2', orderId)
log.info('Order {} processed successfully', orderId)
} catch (Exception e) {
log.error('Failed to process order {}', orderId, e)
}
log.debug('Exiting processOrder')
}
static void main(String[] args) {
def demo = new LogLevelDemo()
demo.demonstrateLevels()
demo.processOrder('ORD-12345')
demo.processOrder(null)
}
}Output
12:34:56.789 [main] DEBUG LogLevelDemo - DEBUG: Diagnostic info - method entry, parameter values 12:34:56.789 [main] INFO LogLevelDemo - INFO: Business events - user logged in, order placed 12:34:56.789 [main] WARN LogLevelDemo - WARN: Something unexpected - slow query, deprecated API call 12:34:56.790 [main] ERROR LogLevelDemo - ERROR: Something broke - exception caught, operation failed 12:34:56.790 [main] DEBUG LogLevelDemo - Entering processOrder with orderId=ORD-12345 12:34:56.790 [main] INFO LogLevelDemo - Processing order ORD-12345 12:34:56.790 [main] INFO LogLevelDemo - Order ORD-12345 processed successfully 12:34:56.790 [main] DEBUG LogLevelDemo - Exiting processOrder 12:34:56.790 [main] DEBUG LogLevelDemo - Entering processOrder with orderId=null 12:34:56.790 [main] INFO LogLevelDemo - Processing order null 12:34:56.791 [main] WARN LogLevelDemo - Null orderId received - this should not happen 12:34:56.791 [main] DEBUG LogLevelDemo - Exiting processOrder
Why TRACE is missing from the output: By default, Logback’s root logger level is set to DEBUG. Messages at TRACE level are below this threshold and get filtered out. You’d set the level to TRACE in your logback.xml configuration to see them – we’ll cover that in Example 5.
When to use each level:
- TRACE – Inner loop details, variable dumps. Only enable during active debugging of a specific issue.
- DEBUG – Method entry/exit, intermediate results. Useful in development and staging.
- INFO – Business-significant events. This is what you typically see in production.
- WARN – Recoverable problems, unusual but non-fatal situations.
- ERROR – Unrecoverable failures, exceptions that need immediate attention.
Example 3: Parameterized Logging – Avoid String Concatenation
One of the biggest performance mistakes in logging is using string concatenation. If the log level is disabled, the concatenation still happens – wasting CPU and memory. SLF4J’s parameterized messages solve this.
Example 3 – Parameterized vs Concatenated Logging
import groovy.util.logging.Slf4j
@Slf4j
class ParameterizedLoggingDemo {
void demonstrateParameterized() {
def username = 'alice'
def orderId = 'ORD-99887'
def amount = 149.99
// BAD: String concatenation - always evaluated, even if DEBUG is off
log.debug('Processing order ' + orderId + ' for user ' + username + ' amount: ' + amount)
// GOOD: Parameterized - placeholders only resolved if DEBUG is enabled
log.debug('Processing order {} for user {} amount: {}', orderId, username, amount)
// GOOD: Single parameter
log.info('User {} logged in', username)
// GOOD: Two parameters
log.info('Order {} total: ${}', orderId, amount)
// GOOD: Multiple parameters (varargs)
log.info('Order {} by {} for ${} completed', orderId, username, amount)
}
void demonstrateGuardCheck() {
// For very expensive operations, you can guard with isXxxEnabled()
if (log.isDebugEnabled()) {
def expensiveReport = generateReport()
log.debug('Full report: {}', expensiveReport)
}
}
private String generateReport() {
log.trace('Generating expensive report...')
// Simulate expensive computation
return (1..100).collect { "item-${it}" }.join(', ')
}
static void main(String[] args) {
def demo = new ParameterizedLoggingDemo()
demo.demonstrateParameterized()
demo.demonstrateGuardCheck()
}
}Output
12:34:56.789 [main] DEBUG ParameterizedLoggingDemo - Processing order ORD-99887 for user alice amount: 149.99 12:34:56.789 [main] DEBUG ParameterizedLoggingDemo - Processing order ORD-99887 for user alice amount: 149.99 12:34:56.789 [main] INFO ParameterizedLoggingDemo - User alice logged in 12:34:56.790 [main] INFO ParameterizedLoggingDemo - Order ORD-99887 total: $149.99 12:34:56.790 [main] INFO ParameterizedLoggingDemo - Order ORD-99887 by alice for $149.99 completed 12:34:56.790 [main] DEBUG ParameterizedLoggingDemo - Full report: item-1, item-2, item-3, ... item-100
Key point: With parameterized messages, the {} placeholders are only resolved when the log level is active. If DEBUG is disabled in production, the string formatting never happens. This can make a real difference in hot paths that execute thousands of times per second.
Example 4: GString vs Parameterized Messages
Groovy developers love GStrings ("Hello ${name}"), but there’s an important nuance when using them with SLF4J. Groovy’s @Slf4j AST transformation actually handles this intelligently.
Example 4 – GString Logging Behavior
import groovy.util.logging.Slf4j
@Slf4j
class GStringLoggingDemo {
void demonstrateGStringBehavior() {
def user = 'alice'
def count = 42
// Approach 1: SLF4J parameterized (recommended for performance)
log.info('User {} has {} items', user, count)
// Approach 2: GString - Groovy's @Slf4j transforms this!
// The AST transformation wraps GString log calls in isXxxEnabled() guards
log.info("User ${user} has ${count} items")
// Approach 3: Plain String with concatenation (avoid this)
log.info('User ' + user + ' has ' + count + ' items')
}
void showASTTransformation() {
def name = 'Bob'
// What you write:
log.debug("Processing request for ${name}")
// What Groovy's @Slf4j AST transformation generates (approximately):
// if (log.isDebugEnabled()) {
// log.debug("Processing request for ${name}")
// }
// This means GStrings are safe to use with @Slf4j - the guard is added automatically!
}
void compareApproaches() {
def items = (1..1000).toList()
// This is safe even with @Slf4j because the AST adds the guard
log.debug("Large list size: ${items.size()}")
// But for truly expensive toString() calls, explicit guard is clearer
if (log.isTraceEnabled()) {
log.trace("Full list contents: ${items}")
}
}
static void main(String[] args) {
def demo = new GStringLoggingDemo()
demo.demonstrateGStringBehavior()
demo.showASTTransformation()
demo.compareApproaches()
}
}Output
12:34:56.789 [main] INFO GStringLoggingDemo - User alice has 42 items 12:34:56.789 [main] INFO GStringLoggingDemo - User alice has 42 items 12:34:56.789 [main] INFO GStringLoggingDemo - User alice has 42 items 12:34:56.790 [main] DEBUG GStringLoggingDemo - Processing request for Bob 12:34:56.790 [main] DEBUG GStringLoggingDemo - Large list size: 1000
The takeaway: Groovy’s @Slf4j annotation is smarter than raw SLF4J. It wraps GString log statements in isXxxEnabled() guards at compile time through the AST transformation. This means you can use GStrings without the performance penalty that would normally come with eager string interpolation. That said, SLF4J parameterized messages ({} placeholders) are still the idiomatic approach and what Java developers will expect to see.
Example 5: Logback Configuration (logback.xml)
Logback is configured via logback.xml (or logback-test.xml for tests) placed in your classpath root – typically src/main/resources/. This is where you control output format, log levels per package, file rotation, and more.
Example 5 – Logback Configuration
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Console appender with colored output -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{36}) - %msg%n</pattern>
</encoder>
</appender>
<!-- File appender with daily rotation -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/application.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
</encoder>
</appender>
<!-- Set specific package levels -->
<logger name="com.myapp.service" level="DEBUG" />
<logger name="com.myapp.repository" level="WARN" />
<logger name="org.hibernate" level="WARN" />
<!-- Root logger: applies to everything not matched above -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
</configuration>Now let’s write Groovy code that exercises this configuration:
Example 5 – Groovy Code Using Logback Config
import groovy.util.logging.Slf4j
@Slf4j
class LogbackConfigDemo {
void showConfigEffect() {
// These will appear based on your logback.xml settings
log.trace('This TRACE message only appears if root level is TRACE')
log.debug('This DEBUG message only appears if root level is DEBUG or lower')
log.info('This INFO message appears with default root level of INFO')
log.warn('This WARN message always appears unless root level is ERROR')
log.error('This ERROR message always appears')
}
void showLoggerName() {
// The logger name is automatically set to the fully qualified class name
// You can see it in the log output pattern as %logger
log.info('Logger name is: {}', log.name)
}
static void main(String[] args) {
def demo = new LogbackConfigDemo()
demo.showConfigEffect()
demo.showLoggerName()
}
}Output
12:34:56.789 [main] INFO LogbackConfigDemo - This INFO message appears with default root level of INFO 12:34:56.789 [main] WARN LogbackConfigDemo - This WARN message always appears unless root level is ERROR 12:34:56.790 [main] ERROR LogbackConfigDemo - This ERROR message always appears 12:34:56.790 [main] INFO LogbackConfigDemo - Logger name is: LogbackConfigDemo
Key configuration patterns:
- %d{pattern} – Date/time formatting
- %thread – Thread name (useful for async/concurrent code)
- %-5level – Log level, left-padded to 5 characters for alignment
- %logger{36} – Logger name, abbreviated to 36 characters
- %msg – The actual log message
- %n – Platform-specific newline
- %highlight() – Color-codes based on level (console only)
- %cyan() – Cyan color for the enclosed content
Example 6: Logging Exceptions with Stack Traces
Logging exceptions properly is essential for debugging production issues. SLF4J has a specific convention: the exception must be the last argument and does not have a corresponding {} placeholder.
Example 6 – Exception Logging
import groovy.util.logging.Slf4j
@Slf4j
class ExceptionLoggingDemo {
void demonstrateExceptionLogging() {
try {
riskyOperation()
} catch (Exception e) {
// CORRECT: Exception as last argument - SLF4J prints the full stack trace
log.error('Operation failed for order {}', 'ORD-123', e)
}
try {
Integer.parseInt('not-a-number')
} catch (NumberFormatException e) {
// CORRECT: Simple message with exception
log.error('Invalid number format', e)
}
try {
def list = []
list.get(5)
} catch (IndexOutOfBoundsException e) {
// CORRECT: With parameterized message and exception
log.warn('Index {} out of bounds for list of size {}', 5, 0, e)
}
}
void demonstrateNestedExceptions() {
try {
try {
throw new FileNotFoundException('config.yml not found')
} catch (FileNotFoundException e) {
throw new RuntimeException('Failed to initialize application', e)
}
} catch (RuntimeException e) {
// The full chain of causes is printed in the stack trace
log.error('Application startup failed', e)
}
}
void demonstrateCommonMistakes() {
try {
riskyOperation()
} catch (Exception e) {
// BAD: Exception message only - no stack trace!
log.error('Failed: ' + e.message)
// BAD: toString only - no stack trace!
log.error('Failed: {}', e.toString())
// GOOD: Full exception with stack trace
log.error('Failed to complete operation', e)
}
}
private void riskyOperation() {
throw new RuntimeException('Something went wrong in the database layer')
}
static void main(String[] args) {
def demo = new ExceptionLoggingDemo()
demo.demonstrateExceptionLogging()
demo.demonstrateNestedExceptions()
}
}Output
12:34:56.789 [main] ERROR ExceptionLoggingDemo - Operation failed for order ORD-123 java.lang.RuntimeException: Something went wrong in the database layer at ExceptionLoggingDemo.riskyOperation(ExceptionLoggingDemo.groovy:53) at ExceptionLoggingDemo.demonstrateExceptionLogging(ExceptionLoggingDemo.groovy:8) ... 2 common frames omitted 12:34:56.790 [main] ERROR ExceptionLoggingDemo - Invalid number format java.lang.NumberFormatException: For input string: "not-a-number" at java.base/java.lang.Integer.parseInt(Integer.java:652) ... 3 common frames omitted 12:34:56.790 [main] WARN ExceptionLoggingDemo - Index 5 out of bounds for list of size 0 java.lang.IndexOutOfBoundsException: Index: 5, Size: 0 ... 3 common frames omitted 12:34:56.790 [main] ERROR ExceptionLoggingDemo - Application startup failed java.lang.RuntimeException: Failed to initialize application at ExceptionLoggingDemo.demonstrateNestedExceptions(ExceptionLoggingDemo.groovy:32) ... 2 common frames omitted Caused by: java.io.FileNotFoundException: config.yml not found at ExceptionLoggingDemo.demonstrateNestedExceptions(ExceptionLoggingDemo.groovy:30) ... 2 common frames omitted
Critical rule: Always pass the exception object as the last argument to the log method. SLF4J detects it automatically and prints the full stack trace including the chain of causes. If you only log e.message or e.toString(), you lose the stack trace – the single most valuable piece of debugging information. For more on Groovy’s exception handling patterns, see our Groovy Exception Handling guide.
Example 7: MDC – Mapped Diagnostic Context
MDC is a thread-local map that lets you attach contextual information (like request IDs, user sessions, or transaction IDs) to every log message produced within that thread. This is invaluable for tracing requests through a multi-layered application.
Example 7 – MDC Usage
import groovy.util.logging.Slf4j
import org.slf4j.MDC
@Slf4j
class MDCDemo {
void handleRequest(String requestId, String userId) {
// Put values into MDC - they attach to every log message on this thread
MDC.put('requestId', requestId)
MDC.put('userId', userId)
try {
log.info('Request received')
processPayment()
sendNotification()
log.info('Request completed successfully')
} finally {
// ALWAYS clear MDC in a finally block to prevent leaking to other requests
MDC.clear()
}
}
private void processPayment() {
log.info('Processing payment')
// The requestId and userId are automatically included in the log output
// based on the logback.xml pattern
log.debug('Payment validated')
log.info('Payment processed')
}
private void sendNotification() {
log.info('Sending notification to user')
log.debug('Notification sent via email')
}
static void main(String[] args) {
def demo = new MDCDemo()
// Simulate two different requests
demo.handleRequest('REQ-001', 'alice')
demo.handleRequest('REQ-002', 'bob')
}
}To see MDC values in your log output, update logback.xml to include %X{key}:
logback.xml with MDC Pattern
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level [%X{requestId}] [%X{userId}] %logger{36} - %msg%n</pattern>Output
12:34:56.789 [main] INFO [REQ-001] [alice] MDCDemo - Request received 12:34:56.789 [main] INFO [REQ-001] [alice] MDCDemo - Processing payment 12:34:56.789 [main] DEBUG [REQ-001] [alice] MDCDemo - Payment validated 12:34:56.789 [main] INFO [REQ-001] [alice] MDCDemo - Payment processed 12:34:56.790 [main] INFO [REQ-001] [alice] MDCDemo - Sending notification to user 12:34:56.790 [main] DEBUG [REQ-001] [alice] MDCDemo - Notification sent via email 12:34:56.790 [main] INFO [REQ-001] [alice] MDCDemo - Request completed successfully 12:34:56.790 [main] INFO [REQ-002] [bob] MDCDemo - Request received 12:34:56.790 [main] INFO [REQ-002] [bob] MDCDemo - Processing payment 12:34:56.790 [main] DEBUG [REQ-002] [bob] MDCDemo - Payment validated 12:34:56.791 [main] INFO [REQ-002] [bob] MDCDemo - Payment processed 12:34:56.791 [main] INFO [REQ-002] [bob] MDCDemo - Sending notification to user 12:34:56.791 [main] DEBUG [REQ-002] [bob] MDCDemo - Notification sent via email 12:34:56.791 [main] INFO [REQ-002] [bob] MDCDemo - Request completed successfully
Why MDC matters: In a web application handling hundreds of concurrent requests, MDC lets you filter logs for a single request by its ID. Without MDC, you’d have to manually pass the request ID to every method and include it in every log call. MDC handles this transparently at the thread level. Just remember to always clear MDC in a finally block – if you’re using thread pools (like in a servlet container), stale MDC values will leak to the next request handled by that thread.
Example 8: Custom Log Formats and Patterns
Different environments need different log formats. Development favors readable, colorful console output. Production may need JSON for log aggregation tools. Here are several useful Logback patterns.
Example 8 – Custom Log Format Patterns
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Pattern 1: Developer-friendly console output -->
<appender name="DEV_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss} %highlight(%-5level) %cyan(%logger{20}) - %msg%n</pattern>
</encoder>
</appender>
<!-- Pattern 2: Production file with full timestamp and thread -->
<appender name="PROD_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>60</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} [%X{requestId:-N/A}] - %msg%n</pattern>
</encoder>
</appender>
<!-- Pattern 3: JSON output for ELK/Splunk (requires logstash-logback-encoder) -->
<!-- Add to build.gradle: implementation 'net.logstash.logback:logstash-logback-encoder:7.4' -->
<!--
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.json</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.json</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder" />
</appender>
-->
<root level="DEBUG">
<appender-ref ref="DEV_CONSOLE" />
</root>
</configuration>And a Groovy class that demonstrates how the same log calls look with different patterns:
Example 8 – Groovy Code for Pattern Testing
import groovy.util.logging.Slf4j
import org.slf4j.MDC
@Slf4j
class CustomFormatDemo {
void demonstrateFormatting() {
MDC.put('requestId', 'REQ-555')
log.info('Application starting up')
log.debug('Configuration loaded from: {}', '/etc/myapp/config.yml')
log.warn('Database connection pool is 80% full')
try {
throw new IllegalStateException('Connection timeout after 30s')
} catch (Exception e) {
log.error('Database operation failed', e)
}
MDC.clear()
}
static void main(String[] args) {
def demo = new CustomFormatDemo()
demo.demonstrateFormatting()
}
}Output (with DEV_CONSOLE pattern)
12:34:56 INFO CustomFormatDemo - Application starting up 12:34:56 DEBUG CustomFormatDemo - Configuration loaded from: /etc/myapp/config.yml 12:34:56 WARN CustomFormatDemo - Database connection pool is 80% full 12:34:56 ERROR CustomFormatDemo - Database operation failed java.lang.IllegalStateException: Connection timeout after 30s at CustomFormatDemo.demonstrateFormatting(CustomFormatDemo.groovy:12) ...
Output (with PROD_FILE pattern)
2026-03-10 12:34:56.789 [main] INFO CustomFormatDemo [REQ-555] - Application starting up 2026-03-10 12:34:56.789 [main] DEBUG CustomFormatDemo [REQ-555] - Configuration loaded from: /etc/myapp/config.yml 2026-03-10 12:34:56.790 [main] WARN CustomFormatDemo [REQ-555] - Database connection pool is 80% full 2026-03-10 12:34:56.790 [main] ERROR CustomFormatDemo [REQ-555] - Database operation failed java.lang.IllegalStateException: Connection timeout after 30s at CustomFormatDemo.demonstrateFormatting(CustomFormatDemo.groovy:12) ...
Choosing the right pattern: In development, keep it short and colorful. In production files, include full timestamps, thread names, and MDC values. For log aggregation (ELK, Splunk, Datadog), use JSON encoding with the logstash-logback-encoder library – it outputs each log event as a single JSON object that these tools can parse natively.
Example 9: @Log, @Log4j, @Log4j2 – Alternative Annotations
Groovy provides several logging annotations beyond @Slf4j. Each one injects a logger from a different logging framework. Here’s when and why you’d use each one.
Example 9 – Alternative Logging Annotations
import groovy.util.logging.Slf4j
import groovy.util.logging.Log // java.util.logging (JUL)
import groovy.util.logging.Log4j // Apache Log4j 1.x (deprecated!)
import groovy.util.logging.Log4j2 // Apache Log4j 2.x
import groovy.util.logging.Commons // Apache Commons Logging
// Option 1: @Slf4j - SLF4J facade (RECOMMENDED)
// Requires: slf4j-api + a binding (logback-classic, slf4j-simple, etc.)
// Logger field: org.slf4j.Logger log
@Slf4j
class Slf4jExample {
void doWork() {
log.info('Using SLF4J via @Slf4j - the recommended approach')
log.debug('Parameterized: value={}', 42)
}
}
// Option 2: @Log - java.util.logging (JUL)
// Requires: Nothing extra (JUL is built into the JDK)
// Logger field: java.util.logging.Logger log
@Log
class JulExample {
void doWork() {
log.info('Using java.util.logging via @Log')
log.fine('This is JUL FINE level (similar to DEBUG)')
log.warning('This is JUL WARNING level')
}
}
// Option 3: @Log4j2 - Apache Log4j 2.x
// Requires: log4j-api + log4j-core
// Logger field: org.apache.logging.log4j.Logger log
@Log4j2
class Log4j2Example {
void doWork() {
log.info('Using Log4j2 via @Log4j2')
log.debug('Log4j2 parameterized: value={}', 42)
}
}
// Option 4: @Commons - Apache Commons Logging
// Requires: commons-logging
// Logger field: org.apache.commons.logging.Log log
@Commons
class CommonsExample {
void doWork() {
log.info('Using Commons Logging via @Commons')
log.debug('Commons debug message')
}
}
// You can also customize the logger name
@Slf4j(value = 'myCustomLogger')
class CustomLoggerName {
void doWork() {
// The field name is now 'myCustomLogger' instead of 'log'
myCustomLogger.info('Custom logger field name')
myCustomLogger.info('Logger name: {}', myCustomLogger.name)
}
}
// Demonstrating the custom category (logger name)
@Slf4j(category = 'com.myapp.audit')
class AuditLogger {
void logAuditEvent() {
// The logger name is 'com.myapp.audit' instead of the class name
log.info('User performed action - logger name: {}', log.name)
}
}
// Run examples
new Slf4jExample().doWork()
new JulExample().doWork()
new CustomLoggerName().doWork()
new AuditLogger().logAuditEvent()Output
12:34:56.789 [main] INFO Slf4jExample - Using SLF4J via @Slf4j - the recommended approach 12:34:56.789 [main] DEBUG Slf4jExample - Parameterized: value=42 Mar 10, 2026 12:34:56 PM JulExample doWork INFO: Using java.util.logging via @Log Mar 10, 2026 12:34:56 PM JulExample doWork WARNING: This is JUL WARNING level 12:34:56.790 [main] INFO CustomLoggerName - Custom logger field name 12:34:56.790 [main] INFO CustomLoggerName - Logger name: CustomLoggerName 12:34:56.790 [main] INFO com.myapp.audit - User performed action - logger name: com.myapp.audit
Which one should you use?
- @Slf4j – The default choice for most projects. SLF4J is the de facto standard Java logging facade, and Logback is the most capable binding.
- @Log – Use if you need zero external dependencies. JUL is built into the JDK but has a clunky API and limited configuration.
- @Log4j2 – Use if your project already uses Log4j 2.x (common in enterprise environments and legacy Spring projects).
- @Log4j – Avoid. Log4j 1.x is end-of-life and has known security vulnerabilities.
- @Commons – Rarely needed. Commons Logging is another facade layer, but SLF4J has largely replaced it.
Example 10: Logging in Scripts with @Grab
If you’re running standalone Groovy scripts (not a full Gradle/Maven project), you can use @Grab to pull in SLF4J and Logback at runtime. This is incredibly handy for quick scripts that still need proper logging.
Example 10 – Script Logging with @Grab
@Grab('org.slf4j:slf4j-api:2.0.12')
@Grab('ch.qos.logback:logback-classic:1.5.6')
import groovy.util.logging.Slf4j
@Slf4j
class ScriptLogger {
void processFiles(String directory) {
log.info('Starting file processing in: {}', directory)
def dir = new File(directory)
if (!dir.exists()) {
log.error('Directory does not exist: {}', directory)
return
}
def files = dir.listFiles()
log.info('Found {} files to process', files?.length ?: 0)
files?.each { file ->
log.debug('Processing file: {} (size: {} bytes)', file.name, file.length())
if (file.name.endsWith('.tmp')) {
log.warn('Skipping temporary file: {}', file.name)
return
}
try {
processFile(file)
log.info('Successfully processed: {}', file.name)
} catch (Exception e) {
log.error('Failed to process file: {}', file.name, e)
}
}
log.info('File processing complete')
}
private void processFile(File file) {
log.trace('Reading contents of: {}', file.absolutePath)
def content = file.text
log.trace('File {} has {} characters', file.name, content.length())
// Process content...
}
}
// Script execution
def logger = new ScriptLogger()
logger.processFiles(args.length > 0 ? args[0] : '.')Output
12:34:56.789 [main] INFO ScriptLogger - Starting file processing in: . 12:34:56.790 [main] INFO ScriptLogger - Found 12 files to process 12:34:56.790 [main] DEBUG ScriptLogger - Processing file: build.gradle (size: 1024 bytes) 12:34:56.790 [main] INFO ScriptLogger - Successfully processed: build.gradle 12:34:56.791 [main] DEBUG ScriptLogger - Processing file: temp.tmp (size: 256 bytes) 12:34:56.791 [main] WARN ScriptLogger - Skipping temporary file: temp.tmp 12:34:56.791 [main] INFO ScriptLogger - File processing complete
How @Grab works with logging: The @Grab annotation triggers Groovy’s Grape dependency manager to download the specified artifacts from Maven Central (or a configured repository) at script startup. The first run may take a few seconds to download; subsequent runs use the cached jars. For more details into @Grab, see our Groovy Grape and @Grab guide.
Tip: If you don’t have a logback.xml on the classpath, Logback uses its default configuration: DEBUG level to console with a basic pattern. You can create a logback.xml in the same directory and add it to the classpath with groovy -cp . ScriptLogger.groovy to pick it up.
Logging vs println – When to Use Which
Let’s be direct about when println is fine and when you absolutely need a logging framework. This isn’t about dogma – it’s about choosing the right tool.
Use println when:
- You’re writing a one-off script that runs once and gets deleted
- You’re learning Groovy and exploring the language
- You’re writing a CLI tool where output is the product (like
lsorgrep) - You’re debugging locally and will remove the print statements before committing
Use a logging framework when:
- The code will run in production
- Multiple developers work on the project
- You need to troubleshoot issues without redeploying
- You need different verbosity in development vs production
- You need log files, rotation, or remote aggregation
- You need to trace requests across services (MDC)
Here’s a side-by-side comparison:
println vs Logging Comparison
import groovy.util.logging.Slf4j
@Slf4j
class ComparisonDemo {
// With println - quick but inflexible
void printlnApproach(String userId) {
println "Looking up user: ${userId}" // Always printed
println "User found" // No timestamp, no level
println "WARNING: user account is locked" // Not a real warning - just text
}
// With logging - configurable and production-ready
void loggingApproach(String userId) {
log.debug('Looking up user: {}', userId) // Can be disabled in prod
log.info('User found') // Timestamped, leveled
log.warn('User account is locked: {}', userId) // Real warning, filterable
}
static void main(String[] args) {
def demo = new ComparisonDemo()
demo.printlnApproach('alice')
println '---'
demo.loggingApproach('alice')
}
}Output
Looking up user: alice User found WARNING: user account is locked --- 12:34:56.789 [main] DEBUG ComparisonDemo - Looking up user: alice 12:34:56.789 [main] INFO ComparisonDemo - User found 12:34:56.789 [main] WARN ComparisonDemo - User account is locked: alice
The logging version gives you timestamps, thread names, log levels, and the logger name – all without extra code. For more on Groovy’s print methods, check our Groovy print vs println guide.
Best Practices for Groovy Logging
After working with logging in dozens of Groovy projects, here are the practices that matter most:
1. Use Parameterized Messages, Not Concatenation
Parameterized Messages Best Practice
// BAD - string concatenation happens even if debug is off
log.debug('User ' + user.name + ' has ' + user.orders.size() + ' orders')
// GOOD - placeholders only resolved when debug is enabled
log.debug('User {} has {} orders', user.name, user.orders.size())
// ALSO GOOD in Groovy - @Slf4j wraps GStrings in isDebugEnabled() check
log.debug("User ${user.name} has ${user.orders.size()} orders")2. Log at the Right Level
Choosing the Right Log Level
// TRACE: Only for deep debugging - loop iterations, variable states
log.trace('Checking item {} of {}', index, total)
// DEBUG: Development diagnostics - method entry/exit, intermediate results
log.debug('findUser called with username={}', username)
// INFO: Business events that matter - user actions, process milestones
log.info('Order {} placed by user {}', orderId, username)
// WARN: Something unexpected that didn't break anything
log.warn('Retrying API call, attempt {} of {}', retryCount, maxRetries)
// ERROR: Something broke - always include the exception object
log.error('Payment processing failed for order {}', orderId, exception)3. Always Pass Exceptions as the Last Argument
Exception Logging Best Practice
// BAD - loses the stack trace
log.error('Failed: ' + e.message)
log.error('Failed: {}', e.message)
// GOOD - full stack trace is printed
log.error('Failed to process order {}', orderId, e)
log.error('Unexpected error', e)4. Use MDC for Request Tracing
MDC Best Practice
// Set MDC at the entry point (servlet filter, message listener, etc.)
MDC.put('requestId', UUID.randomUUID().toString())
MDC.put('userId', authenticatedUser.id)
try {
// All log messages in this thread now include requestId and userId
handleRequest()
} finally {
MDC.clear() // ALWAYS clear in a finally block
}5. Don’t Log Sensitive Data
Sensitive Data Best Practice
// BAD - passwords, tokens, and PII in logs
log.debug('Authenticating user={} password={}', username, password)
log.info('Credit card: {}', creditCardNumber)
// GOOD - redact or mask sensitive data
log.debug('Authenticating user={}', username)
log.info('Credit card: ****{}', creditCardNumber[-4..-1])6. Keep Log Messages Meaningful
Meaningful Log Messages
// BAD - meaningless noise
log.info('here')
log.debug('step 1')
log.info('done')
// GOOD - tells you what happened, with context
log.info('Order validation started for orderId={}', orderId)
log.debug('Inventory check passed: {} units available', availableUnits)
log.info('Order {} validated successfully in {}ms', orderId, elapsed)7. Configure Log Levels Per Package
Per-Package Log Level Configuration
<!-- In logback.xml -->
<!-- Your application code: DEBUG in dev, INFO in prod -->
<logger name="com.myapp" level="DEBUG" />
<!-- Noisy third-party libraries: keep quiet -->
<logger name="org.hibernate" level="WARN" />
<logger name="org.springframework" level="WARN" />
<logger name="org.apache.http" level="WARN" />
<!-- SQL logging: enable only when debugging queries -->
<logger name="org.hibernate.SQL" level="DEBUG" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>Conclusion
Groovy logging with @Slf4j is one of the simplest and most impactful improvements you can make to any Groovy project. A single annotation gives you a production-grade logger with zero boilerplate, and Groovy’s AST transformation even protects you from GString performance pitfalls by adding level guards automatically.
Here’s what we covered:
- @Slf4j annotation injects a logger at compile time via AST transformation
- Five log levels (TRACE, DEBUG, INFO, WARN, ERROR) give you precise control over output verbosity
- Parameterized messages avoid string concatenation overhead when log levels are disabled
- GStrings work safely with @Slf4j because Groovy adds isXxxEnabled() guards at compile time
- Logback configuration via logback.xml controls formatting, destinations, rotation, and per-package levels
- Exception logging requires passing the exception as the last argument for full stack traces
- MDC enables request-level tracing without passing context through every method
- Alternative annotations (@Log, @Log4j2, @Commons) exist for other logging frameworks
- @Grab makes logging available in standalone scripts without a build tool
Start with @Slf4j and a simple logback.xml. As your application grows, add MDC for request tracing, configure per-package levels to silence noisy libraries, and consider JSON output for log aggregation. The investment is minimal and the payoff in debuggability is enormous.
Up next: Groovy Shell – Interactive Groovy Console
Frequently Asked Questions
What does @Slf4j do in Groovy?
The @Slf4j annotation is a Groovy AST transformation from groovy.util.logging that automatically injects a private static final org.slf4j.Logger log field into your class at compile time. This eliminates the boilerplate of manually calling LoggerFactory.getLogger(). The logger name defaults to the fully qualified class name. Additionally, Groovy’s @Slf4j wraps GString-based log messages in isXxxEnabled() guards to prevent unnecessary string interpolation.
How do I configure log levels in Groovy with Logback?
Create a logback.xml file in src/main/resources/ (or the classpath root). Use <root level="INFO"> to set the default level, and <logger name="com.myapp" level="DEBUG" /> to set levels per package. The five levels from lowest to highest severity are TRACE, DEBUG, INFO, WARN, and ERROR. Setting a level means that level and all higher levels are enabled – for example, setting INFO enables INFO, WARN, and ERROR.
Should I use GStrings or parameterized messages with @Slf4j?
Both are safe with Groovy’s @Slf4j. SLF4J parameterized messages (log.info('User {} logged in', username)) are the standard Java approach and skip string formatting when the level is disabled. GStrings (log.info("User ${username} logged in")) are also safe because Groovy’s AST transformation wraps them in isXxxEnabled() guards. Use parameterized messages for consistency with Java codebases, or GStrings when they’re more readable – Groovy handles both efficiently.
How do I log exceptions properly in Groovy?
Always pass the exception object as the last argument to the log method, without a corresponding {} placeholder. For example: log.error('Operation failed for order {}', orderId, exception). SLF4J automatically detects the exception and prints the full stack trace including the chain of causes. Never log just e.message or e.toString() – you lose the stack trace, which is the most valuable piece of debugging information.
Can I use logging in standalone Groovy scripts without Gradle or Maven?
Yes. Use Groovy’s @Grab annotation to pull in SLF4J and Logback at runtime: @Grab('org.slf4j:slf4j-api:2.0.12') and @Grab('ch.qos.logback:logback-classic:1.5.6'). Then annotate your class with @Slf4j as usual. The first run downloads the dependencies; subsequent runs use cached jars. Without a logback.xml, Logback defaults to DEBUG level on the console.
Related Posts
Previous in Series: Groovy ConfigSlurper – Configuration Management Guide
Next in Series: Groovy Shell – Interactive Groovy Console
Related Topics You Might Like:
- Groovy Grape and @Grab – Dependency Management
- Groovy Exception Handling – Complete Guide
- Groovy AST Transformations – Metaprogramming
This post is part of the Groovy Cookbook series on TechnoScripts.com

No comment