On This Page
There's no single Spring Boot failure banner. There are about forty of them, spread across roughly fourteen jars. If none of those forty recognizes what went wrong, you don't get a banner at all. You get a raw stack trace, Application run failed, and whatever Caused by: chain your JVM decided to print. Nobody tells you which situation you're in. You just learn to recognize the shape over time.
I'd used this banner for years before I actually went and found where it lives. It's a decade-old mechanism that's grown a lot more than it's changed, and Boot 4.1 quietly added a second, much smaller way to plug into it that most of the internet hasn't caught up to yet. This is the writeup: where the banner comes from, why it sometimes doesn't show up at all, and how you tell which case you're in without guessing.
The banner you may or may not have gotten
Here's one, from a real (if artificial) failure: two configuration properties that shouldn't both be set at once.
***************************
APPLICATION FAILED TO START
***************************
Description:
The following configuration properties are mutually exclusive:
app.export.directory
app.export.file
However, more than one of those properties has been configured at the same time:
app.export.directory (originating from 'class path resource [application.properties] - 2:22')
app.export.file (originating from 'class path resource [application.properties] - 1:17')
Action:
Update your configuration so that only one of the mutually exclusive properties is configured.And here's the same class of mistake, a different exception, same Boot version, same app, same JVM:
2026-09-16T09:14:02.311Z ERROR 41213 --- [ main] o.s.boot.SpringApplication : Application run failed
java.lang.IllegalStateException: pretend third-party SDK could not find its license file
at com.example.repro.demo.UnanalyzedFailureDemo.explode(UnanalyzedFailureDemo.java:13)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...No asterisks. No Description:. No Action:. The exception, logged once, at ERROR, and that's the whole message. Both of these come from a @PostConstruct method throwing during context refresh. Both kill the app the same way, same exit code, same dead process. One got the nice treatment. One didn't. That gap is the whole article.
Boot version for everything here is 4.1.1, Spring Framework 7.0.9, Java 21, Maven. This is a pure startup failure, both times: it happens during context refresh, before the app has taken a single request, and neither case needs a web server or a database to show up. I checked the mechanism itself back to 3.5.16 too, and it hasn't moved. What changed between those two releases is the size and shape of the catalog, which is section 8's problem.
Is there even a catalog entry for what you're looking at
Before you go digging into why your DataSource won't build or your bean won't wire, spend ten seconds on a cheaper question: did Spring even recognize this failure as one it has a canned explanation for?
The tell is right there in the log. If you see the asterisk banner, something matched, and the Description and Action blocks are worth reading closely: they were built for this exact exception. If you see Application run failed followed by a bare stack trace, nothing matched, and you're on your own. Read the Caused by: chain from the bottom up, because that's where the real failure usually sits, several exceptions removed from the one on top.
There's no endpoint for this question. Actuator isn't up yet; the app never got that far, so /actuator/conditions and friends are the right move for a bean that got skipped during auto-configuration, not for a startup crash. The condition report from --debug won't help either. It tells you what got evaluated, not what got analyzed after the fact. The log line itself is the only place to look.
What you're really checking is whether your root cause belongs to the roughly forty classes Boot ships that know how to explain themselves. A short slice of that catalog, so you don't have to memorize all forty:
NoSuchBeanDefinitionException core/spring-boot-autoconfigure
BeanCurrentlyInCreationException core/spring-boot
NoUniqueBeanDefinitionException core/spring-boot
Port already in use (embedded server) module/spring-boot-web-server
DataSource won't configure module/spring-boot-jdbc
Hikari driver misconfiguration module/spring-boot-jdbc
Liquibase changelog missing module/spring-boot-liquibase
Tomcat connector fails to start module/spring-boot-tomcat
Mutually exclusive config properties core/spring-boot
@ConfigurationProperties validation core/spring-bootI've already written two of these up in depth: the DataSource won't configure itself case, and the scan-root NoSuchBeanDefinitionException case. Both go through the exact mechanism below. BeanCurrentlyInCreationException and the port-in-use case are older posts on this site and go through it too, even though I didn't frame them that way at the time.
Worth saying plainly: LazyInitializationException, TransactionRequiredException, the CSRF 403, and the Bean Validation 500, four other failures I've written up here, are not in this catalog and never will be. They're request-time failures. This mechanism runs exactly once, during SpringApplication.run(), before your app has taken a single request. If your failure happened on request seventeen, you won't find it here. That's not a gap in the catalog. It's a boundary in what the catalog is for.
Reproduce both outcomes on your laptop
No database, no Docker, no web server. This is a pure context-refresh failure, so the whole demo is two POJOs and a properties file.
What you need: Boot 4.1.1, Java 21, Maven. start.spring.io was unreachable for me again this run, eighth run straight at this point, so here's the pinned Initializr call and the dependency you'd add by hand if you're offline too:
curl https://start.spring.io/starter.zip \
-d type=maven-project \
-d bootVersion=4.1.1 \
-d javaVersion=21 \
-d groupId=com.example -d artifactId=repro -d name=repro \
-d packageName=com.example.repro \
-o repro.zip && unzip repro.zip -d repro && cd repro<!-- the only dependency this needs beyond the parent BOM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>The code. A @ConfigurationProperties class that checks itself on @PostConstruct, using a static helper Boot already ships:
package com.example.repro.export;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException;
@ConfigurationProperties(prefix = "app.export")
public class ExportProperties {
private String file;
private String directory;
public String getFile() { return this.file; }
public void setFile(String file) { this.file = file; }
public String getDirectory() { return this.directory; }
public void setDirectory(String directory) { this.directory = directory; }
@PostConstruct
void checkMutuallyExclusive() {
MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
entries.put("app.export.file", this.file);
entries.put("app.export.directory", this.directory);
});
}
}package com.example.repro;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.example.repro.export.ExportProperties;
@SpringBootApplication
@EnableConfigurationProperties(ExportProperties.class)
public class ReproApplication {
public static void main(String[] args) {
SpringApplication.run(ReproApplication.class, args);
}
}A second bean covers the no-banner half, gated so it only fires when you ask for it:
package com.example.repro.demo;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Component
@ConditionalOnProperty(name = "app.demo.trigger-unanalyzed-failure", havingValue = "true")
public class UnanalyzedFailureDemo {
@PostConstruct
void explode() {
throw new IllegalStateException("pretend third-party SDK could not find its license file");
}
}Trigger the banner. In application.properties:
app.export.file=/var/exports/report.csv
app.export.directory=/var/exportsStep 1 — build and run
$ ./mvnw -q spring-boot:run
Expected: dies in under 2 seconds. Last thing printed is the asterisk
banner from section 1, verbatim, including the mutually-exclusive-names
list sorted alphabetically. Exit code 1.Trigger the raw trace instead. Comment out both app.export.* lines, add app.demo.trigger-unanalyzed-failure=true:
Step 2 — same app, different property
$ ./mvnw -q spring-boot:run
Expected: dies just as fast, but now it's the bare stack trace from
section 1's second block. No asterisks anywhere in the output.
Exit code 1, same exit code as step 1, which is itself worth noticing.Confirmation signal. Grep the output for APPLICATION FAILED TO START. Present in step 1, absent in step 2. If you get neither, and instead a clean Started ReproApplication, you left both export properties unset or the demo property isn't true. If you see a different banner mentioning IllegalStateException wrapped as a BeanCreationException, you're on an older Boot where @PostConstruct failures get wrapped differently. That's a version difference worth noting, not a broken repro.
Teardown. There isn't any. No port got bound, no file got written, nothing's listening. rm -rf repro and you're done. That's a small point in this article's favor on its own: a pure diagnostics failure shouldn't need Docker to reproduce. If a "Spring Boot error" repro you find online insists on one, ask whether the failure is really about Spring Boot or about whatever's inside the container.
The short version
The banner isn't generic. Every asterisk block you've seen was built by one specific Java class that recognized your specific exception and knew what to say about it. When startup fails, Boot hands the exception to a list of roughly forty of these classes, one at a time, and asks each one: do you know what this is? The first one to say yes wins, and its answer becomes the banner. If all forty say no, there's nothing left to try, and you get the exception printed the ordinary way, which is really just the JVM's default behavior with one logging line wrapped around it.
Forty analyzers, fourteen jars, one loop
The interface is FailureAnalyzer, and it's been around since Boot 1.4.0. This part of Spring Boot is genuinely old and hasn't changed shape since. One method, analyze(Throwable), returns a FailureAnalysis (a description plus an optional action) or null. Most implementations extend AbstractFailureAnalyzer<T>, whose analyze does the boring part for you. It walks the entire cause chain looking for an instance of T, all the way down, past the outermost exception:
public abstract class AbstractFailureAnalyzer<T extends Throwable> implements FailureAnalyzer {
public FailureAnalysis analyze(Throwable failure) {
T cause = findCause(failure, getCauseType());
return (cause != null) ? analyze(failure, cause) : null;
}
// findCause walks failure.getCause() in a loop until it finds
// an instanceof match, or runs out of chain.
}That's why an analyzer written for BeanCurrentlyInCreationException still fires when the exception on top is some UnsatisfiedDependencyException wrapping it three levels down. It isn't matching the exception you see. It's matching something buried inside it.
Who runs the loop is a package-private class called, unglamorously, FailureAnalyzers. It loads every registered FailureAnalyzer off the classpath, appends one more at the very end (more on that shortly), and iterates:
private FailureAnalysis analyze(Throwable failure, List<FailureAnalyzer> analyzers) {
for (FailureAnalyzer analyzer : analyzers) {
try {
FailureAnalysis analysis = analyzer.analyze(failure);
if (analysis != null) {
return analysis;
}
}
catch (Throwable ex) {
// logged at TRACE, then the loop moves on
}
}
return null;
}First non-null answer wins. No scoring, no "best match," no tie-breaking by specificity. Whichever analyzer sits earlier in the list gets to answer, even if a later one would give a more precise explanation. And here's where it gets a little uncomfortable: that list order isn't documented or guaranteed anywhere. It's built by SpringFactoriesLoader, which calls classLoader.getResources("META-INF/spring.factories"), plain ClassLoader.getResources(), enumerating every spring.factories file across every jar on your classpath, and appends each file's entries in whatever order the classloader hands the jars back, then de-duplicates while keeping first-seen order. In practice that's your build tool's classpath order. It's stable for a given build, but not something you control cleanly, and definitely not something a @Conditional or an @Order can touch, because none of that machinery is running yet. Register your own FailureAnalyzer for an exception type Boot already has one for, and whoever wins is a function of jar ordering you didn't choose.
I went looking for whether this registration ever moved to the newer AutoConfiguration.imports file format, the way real auto-configuration classes did back in 2.7. It didn't. Every module I checked, spring-boot-jdbc, spring-boot-web-server, spring-boot-tomcat, spring-boot-liquibase, all the way down, still registers its analyzers the old way: META-INF/spring.factories, key org.springframework.boot.diagnostics.FailureAnalyzer. Auto-configuration classes moved. This didn't. I don't have a clean reason why beyond "it works and nothing forced a change," and I'd rather say that plainly than invent a design rationale Spring's own team never wrote down anywhere I could find.
Now, the one appended at the end of every list, unconditionally: FailureAnalyzedException::analyze. This is new, @since 4.1.0, which means it landed in the release from this past August, and I haven't seen it mentioned outside Boot's own Javadoc yet. It's a plain public class you can extend directly:
public class FailureAnalyzedException extends RuntimeException {
public FailureAnalyzedException(String description, String action) { /* ... */ }
public FailureAnalyzedException(String description, String action, Throwable cause) { /* ... */ }
}Throw a subclass of it anywhere in your app and it gets the banner treatment automatically. No FailureAnalyzer class to write, no spring.factories entry to add. FailureAnalyzedException::analyze walks the chain the same way AbstractFailureAnalyzer does, checking instanceof FailureAnalyzedException, and builds the FailureAnalysis straight from whatever description and action you passed the constructor. It's checked dead last, after all forty registered analyzers, which makes sense: if Boot already has a real analyzer for your exception, that one should win over your generic self-report.
On the fallback side, when the loop returns null, or when it returns an analysis but every registered FailureAnalysisReporter is somehow empty, SpringApplication.reportFailure does exactly what step 2 of the repro showed:
private void reportFailure(Collection<SpringBootExceptionReporter> exceptionReporters, Throwable failure) {
for (SpringBootExceptionReporter reporter : exceptionReporters) {
if (reporter.reportException(failure)) {
registerLoggedException(failure);
return;
}
}
logger.error("Application run failed", failure);
registerLoggedException(failure);
}registerLoggedException is the other half of something I didn't expect to find. Boot installs its own UncaughtExceptionHandler on the main thread, SpringBootExceptionHandler, specifically so the JVM's default handler doesn't also print the same stack trace a second time once the exception propagates past main(). That same class is what turns a non-zero exit code into an actual System.exit() call. So the one, singular stack trace you see, and the process actually dying instead of just the thread quietly finishing, are both this small class's job. I'd never have gone looking for it if I hadn't been chasing this exact question anyway: why does one exception get the nice banner and the other doesn't. What felt like it should've been a five-minute answer took most of a day, because every file led to one more.
What to change, and what you get for free
There's no "fix" here in the usual sense. The repro isn't broken; it's demonstrating correct behavior. But there's a real choice buried in it: how do you want your own startup checks to fail?
The version most people reach for, copied from a Baeldung post that's been circulating since roughly Boot 1.x, is a full custom FailureAnalyzer: a class implementing the interface, a registration entry, and your own exception type as the target. It still works, and it's still the right call when the exception you're analyzing comes from a library you don't own. You can't add a constructor to someone else's RuntimeException.
For your own exceptions, on 4.1+, none of that is necessary. Extend FailureAnalyzedException, throw it, done:
public class ExportDestinationMisconfigured extends FailureAnalyzedException {
public ExportDestinationMisconfigured(String file, String directory) {
super(
"Both app.export.file (" + file + ") and app.export.directory (" + directory
+ ") are set. Export supports exactly one destination.",
"Remove one of app.export.file or app.export.directory from your configuration.");
}
}What each approach costs: a full FailureAnalyzer is more code and one more registration file to remember exists, but it can inspect the original exception type, useful when you're analyzing something you can't modify. FailureAnalyzedException is two constructor arguments and nothing else to register, but it only works for exceptions you throw yourself, and it always loses a tie-break to a real analyzer for the same root cause, since it's checked last.
The non-fix worth flagging in review: hand-rolling your own mutual-exclusivity check as if (a != null && b != null) throw new IllegalStateException(...). It isn't wrong exactly; it'll stop the app the same way. But you lose the free banner, the alphabetized property list, and the origin tracking, for no reason beyond not knowing MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn() already existed, since 2.6.0.
Wire into the catalog instead of rolling your own
The better habit, once you know the catalog is there, is to reach for it before reaching for a plain exception. Boot ships more of these ready-made throw-and-forget helpers than the mutually-exclusive one: BindValidationException for @ConfigurationProperties validation failures, InvalidConfigurationPropertyValueException for "this property has a value, but it isn't a legal one," UnboundConfigurationPropertyException for the reverse case. Each already has an analyzer sitting in core/spring-boot waiting for it. If your own startup validation is throwing a bare IllegalStateException today, there's a real chance one of these already fits and you just haven't gone looking.
Where none of them fit, genuine custom validation, your own domain rule, that's exactly what 4.1's FailureAnalyzedException is for. I'd treat it as the default now, ahead of writing a full FailureAnalyzer, for anything where you own both the exception and the throw site.
What's stable, what's new in 4.1, what to watch
The mechanism itself, FailureAnalyzer, AbstractFailureAnalyzer, LoggingFailureAnalysisReporter, the first-match-wins loop, is the oldest, steadiest part of this whole story. @since 1.4.0. It won't surprise you on an upgrade.
What will surprise you, jumping from a 3.x line straight to 4.1, is where things moved. In 3.5.16 the entire catalog lives in three files: spring-boot's own spring.factories (twenty-ish analyzers, including the port-in-use one, right there in core), spring-boot-autoconfigure's (the DataSource and Hikari ones, a Redis URL one, a handful more), and spring-boot-actuator-autoconfigure's. Three files, thirty-eight analyzers, easy to grep. By 4.1.1 that same catalog, forty analyzers now, a couple net-new, is scattered across fourteen separate modules: spring-boot-jdbc has its own spring.factories, so does spring-boot-web-server, spring-boot-tomcat, spring-boot-liquibase, spring-boot-data-redis, spring-boot-jooq, spring-boot-mail, and more. The port-in-use analyzer specifically moved out of core into module/spring-boot-web-server. It isn't a removal. It's Boot 4's general modularization landing on this one corner too. But if you go looking for the catalog the old way, one grep across two familiar files, you'll only find a third of it.
If you're staying on 3.5.x for now (support runs to mid-2026 per Spring's own published policy, worth reconfirming against spring.io/projects/spring-boot#support whenever you're reading this, since I won't pretend a support table stays fixed), none of the FailureAnalyzedException material here applies to you yet. That constructor doesn't exist before 4.1.0. Everything else, the loop, the classpath-order caveat, the catalog concept itself, has been true since long before 3.5 and will keep being true after 4.1.
If you remember nothing else
The banner means an analyzer recognized your exception; a raw stack trace means none of roughly forty did, and that's a fact about the catalog, not a verdict on your code. The first matching analyzer wins, checked in an order Spring doesn't document and you don't control. Boot 4 spread that catalog across fourteen modules instead of three files, same size increase, much harder to grep. Since 4.1, if it's your own exception, you can skip writing a FailureAnalyzer entirely and just extend FailureAnalyzedException.
Sources
FailureAnalyzer,AbstractFailureAnalyzer,FailureAnalyzers,LoggingFailureAnalysisReporter,FailureAnalyzedException(spring-boot v4.1.1)SpringApplication.reportFailure/handleRunFailureandSpringBootExceptionHandler(spring-boot v4.1.1)MutuallyExclusiveConfigurationPropertiesException(spring-boot v4.1.1)SpringFactoriesLoader(spring-framework v7.0.9)TextResourceOrigin/PropertySourceOrigin(spring-boot v4.1.1)- Analyzer registration diffed directly via
spring.factoriesin spring-boot v3.5.16 vs v4.1.1, and across the fourteenmodule/*directories in the v4.1.1 tree - Spring Boot support policy, checked this run; Track A (Stack Overflow's Hot page and the Stack Exchange API) was unreachable through the egress proxy for the eighth consecutive run, so this pick came from evergreen search demand and the source itself rather than current SO activity