Skip to content
CalliCoder

How to Use Log4j 2 with Spring Boot

Published Updated Spring Boot 12 min read

Swap Logback for Log4j 2 without leaving two logging backends on the classpath: the exclusion that actually matters, why the file must be called log4j2-spring.xml, rolling files that delete themselves, and what async logging costs.

Spring Boot logs through Logback by default and does it well, so switching to Log4j 2 should be a decision rather than a habit. The reasons that hold up are asynchronous loggers with genuinely low latency, a richer set of appenders, and plugin extensibility. The reason that does not hold up is familiarity with Log4j 1, which shares almost nothing with Log4j 2 beyond the name.

This walks through the swap on Spring Boot 3.2 with Java 17, and the four places it usually goes wrong.

The exclusion is the whole trick

spring-boot-starter-web depends on spring-boot-starter, which depends on spring-boot-starter-logging, which brings Logback. Adding Log4j 2 without removing that leaves two SLF4J bindings on the classpath, and SLF4J picks one at random and warns about the other. Half the “my configuration is being ignored” reports are this: the configuration is fine, the other backend is serving.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

The exclusion has to be repeated on every starter that pulls spring-boot-starter, which is most of them. Maven’s dependency:tree settles it in one command:

$ ./mvnw dependency:tree -Dincludes=ch.qos.logback

Empty output means the swap is clean. Gradle can do it once, globally:

configurations.all {
    exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}

Never pin a Log4j version yourself. The Spring Boot parent manages it, and a hand-pinned version is how a project ends up on a release predating the 2021 remote-code-execution fixes.

Name the file log4j2-spring.xml

Log4j 2 picks up log4j2.xml from the classpath on its own, before Spring starts. That works, and it costs you every Spring integration. Rename it and Spring Boot takes over initialisation:

src/main/resources/log4j2-spring.xml

Two features exist only with the -spring name, because only then is a Spring context available when the configuration is read:

  • <SpringProfile> blocks, so one file can configure several environments
  • the spring: lookup, for reading resolved Spring properties into the config

That is the difference between one file with profile blocks and four near-identical files.

A configuration worth starting from

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Properties>
        <Property name="LOG_PATTERN">
            %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%t] %logger{36} - %msg%n
        </Property>
        <Property name="APP_NAME">${spring:spring.application.name:-app}</Property>
    </Properties>

    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="${LOG_PATTERN}"/>
        </Console>
    </Appenders>

    <Loggers>
        <Logger name="com.example.notes" level="debug" additivity="false">
            <AppenderRef ref="Console"/>
        </Logger>
        <Root level="info">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

Three details in there are worth naming.

status="WARN" on the root element controls Log4j’s logging about itself. Set it to debug temporarily when a configuration seems to be ignored: it prints which file it loaded and which plugins it resolved, which answers the question immediately.

additivity="false" stops a message matching both the specific logger and Root from being written twice. Leaving it out is the usual cause of duplicated lines.

${spring:spring.application.name:-app} reads a Spring property with a fallback after :-. Only available in a -spring file.

Profile-specific blocks

<Loggers>
    <SpringProfile name="dev | local">
        <Root level="debug">
            <AppenderRef ref="Console"/>
        </Root>
    </SpringProfile>

    <SpringProfile name="prod">
        <Root level="warn">
            <AppenderRef ref="RollingFile"/>
        </Root>
    </SpringProfile>
</Loggers>

name accepts an expression: dev | local for either, !prod for negation. Development gets a readable console; production gets a file and a quieter floor.

Rolling files that clean up after themselves

A file appender that grows without limit fills the disk, and a full disk takes the application down with it. Log4j 2 handles rotation and retention in one appender:

<RollingFile name="RollingFile"
             fileName="logs/notes.log"
             filePattern="logs/notes-%d{yyyy-MM-dd}-%i.log.gz">
    <PatternLayout pattern="${LOG_PATTERN}"/>
    <Policies>
        <TimeBasedTriggeringPolicy interval="1" modulate="true"/>
        <SizeBasedTriggeringPolicy size="50 MB"/>
    </Policies>
    <DefaultRolloverStrategy max="10">
        <Delete basePath="logs" maxDepth="1">
            <IfFileName glob="notes-*.log.gz"/>
            <IfLastModified age="30d"/>
        </Delete>
    </DefaultRolloverStrategy>
</RollingFile>

The .gz suffix on filePattern is not decoration: Log4j compresses on rollover because of it, and text logs compress by roughly a factor of ten.

The two policies combine as either: roll at midnight, or at 50 MB, whichever comes first. Both %d and %i must appear in the pattern for that to work; drop %i and the second roll of a day overwrites the first.

max="10" and the Delete block do different jobs. max caps the %i counter within one rollover period; Delete is what actually enforces a retention window across days. Set max alone and you keep ten files per day, forever.

Asynchronous logging, and what it costs

Logging is synchronous by default: the calling thread formats the message and writes it. Under load, threads serialise on the appender.

Log4j 2’s asynchronous loggers hand the event to a ring buffer and return, which is where the latency claims come from. They need one extra dependency:

<dependency>
    <groupId>com.lmax</groupId>
    <artifactId>disruptor</artifactId>
    <version>3.4.4</version>
</dependency>

All loggers async, set before the JVM touches Log4j:

-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector

Or selectively, which is usually the better trade:

<Loggers>
    <AsyncLogger name="com.example.notes.access" level="info" includeLocation="false">
        <AppenderRef ref="RollingFile"/>
    </AsyncLogger>
    <Root level="warn">
        <AppenderRef ref="Console"/>
    </Root>
</Loggers>

Two consequences to accept before turning this on.

Events in the buffer are lost if the process dies. A crash takes the last few thousand lines with it, precisely the lines describing the crash. Register the shutdown hook, and keep error-level logging synchronous if post-mortem completeness matters more than throughput.

includeLocation="true" destroys the benefit. Class name, method and line number are recovered by walking the stack, and on an async logger that walk happens on the consumer thread with a snapshot taken on the producer, the cost is high enough to erase the gain. It defaults to false for async loggers. Leave it there and put the context in the message.

Levels from application.properties still work

The Spring properties you already use keep working; Boot translates them onto whichever backend is active:

logging.level.root=info
logging.level.com.example.notes=debug
logging.level.org.hibernate.SQL=debug

They are applied after the XML, so they win. That is convenient and occasionally confusing: a level set in log4j2-spring.xml can be silently overridden by a property in application.properties. Pick one place per logger.

For runtime changes without a restart, Actuator’s /actuator/loggers endpoint will POST a new level onto a running process, far better than redeploying to see a stack trace.

About the SMTP appender

Log4j 2 can email log events, given a mail implementation on the classpath (jakarta.mail on Spring Boot 3, not javax.mail). It is a poor alerting mechanism and worth saying so plainly: the incident that produces one error usually produces thousands, your mail provider rate-limits you partway through, and the messages that survive arrive without the context to act on. Route errors to whatever aggregates and deduplicates them, and let that decide when to page someone.

Frequently asked questions

Do I have to exclude spring-boot-starter-logging?

Yes. Without it, Logback and Log4j 2 are both on the classpath, SLF4J binds to one of them and warns about the other, and your configuration appears to be ignored. Confirm with ./mvnw dependency:tree -Dincludes=ch.qos.logback.

Why is my log4j2.xml being ignored?

Either a second backend is winning (see above), or the file is not on the classpath root. Set status="debug" on <Configuration> and Log4j prints which file it loaded during startup.

What is the difference between log4j2.xml and log4j2-spring.xml?

Log4j reads log4j2.xml itself, before Spring exists. With the -spring name, Spring Boot performs initialisation, which is what enables <SpringProfile> blocks and the spring: property lookup.

Why are my log lines printed twice?

A logger and Root both have an appender attached and the specific logger inherits. Set additivity="false" on it.

Do I need the disruptor dependency?

Only for asynchronous loggers. AsyncAppender does not need it; AsyncLogger and the async context selector do.

Is async logging safe for error logs?

Not fully. Events queued in the ring buffer are lost if the process dies, which is the worst possible moment to lose them. Keep error paths synchronous, or accept the gap knowingly.

Why did async logging not make anything faster?

Most often includeLocation="true". Recovering class and line number requires a stack walk that costs more than the queueing saves. It is false by default on async loggers for that reason.

Can I still use logging.level properties?

Yes, and they are applied after the XML, so they override it. Avoid configuring the same logger in both places.

How do I change a level without restarting?

POST to /actuator/loggers/<name> with {"configuredLevel":"DEBUG"}. Requires the loggers endpoint to be exposed.

Should I pin a Log4j version?

No. The Spring Boot parent manages a current one; a hand-pinned version is how projects end up on a release with known remote-code-execution issues.

Where should I go next?

Spring Boot Actuator covers the endpoint that changes log levels at runtime, and the rest of the Spring Boot guides build on the same application.