Groovy Logging with @Slf4j – Complete Guide with 10 Tested Examples

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.

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. println only goes to stdout.
  • Performance – Logging frameworks use lazy evaluation and parameterized messages. String concatenation in println happens 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 ls or grep)
  • 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.

Previous in Series: Groovy ConfigSlurper – Configuration Management Guide

Next in Series: Groovy Shell – Interactive Groovy Console

Related Topics You Might Like:

This post is part of the Groovy Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *