On This Page
What actually printed
I fixed a @NotBlank violation on a @ConfigurationProperties class, redeployed, and got the exact same startup failure. Not a similar one. Byte-for-byte the same first line. That's the moment this article started.
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.example.repro.MailProperties failed:
Property: app.mail.hots
Value: "smtp.example.com"
Origin: class path resource [application.yml] - 4:10
Reason: The elements [app.mail.hots] were left unbound.
Action:
Update your application's configurationThat's UnboundConfigurationPropertiesException. A typo'd key, app.mail.hots instead of app.mail.host. Here's the other one, from a run where I'd left app.mail.host blank:
***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.example.repro.MailProperties failed:
Property: app.mail.host
Value: ""
Origin: class path resource [application.yml] - 3:11
Reason: must not be blank
Action:
Update your application's configurationSame opening sentence, Binding to target com.example.repro.MailProperties failed:. Same overall shape. Completely different bug. One is Bean Validation rejecting a value that bound fine; the other is Spring Boot refusing to start because a property under your prefix was never claimed by anything. I'm running Spring Boot 4.1.1 (Spring Framework 7.0.9), but I checked this back to 3.5.16 and the wording hasn't moved in years. This isn't a version thing. It's how the banner gets built.
There are two more shapes worth knowing before you go further. A plain type mismatch (port: notanumber against an int field) prints Failed to bind properties under 'app.mail.port' to java.lang.Integer: instead, no "Binding to target" at all. And a value that's the right type but fails a check you wrote yourself gets a fourth, nicer-looking banner: Invalid value '99' for configuration property 'app.mail.retries'... Validation failed for the following reason: Must be between 0 and 10. Four banners, one root mechanism. Two of them are, on paper, indistinguishable at a glance, and that's what I want to walk through.
Which of Boot's own checks is actually complaining
This one's entirely Spring Boot's own machinery. No Hibernate, no Jackson, nothing else in the loop. So "which component failed" here really means "which of Boot's internal bind handlers threw," and the banner alone doesn't always tell you. The fastest way to find out is to stop reading the rewritten Description block and go look at what actually got thrown:
logging.level.org.springframework.boot.diagnostics=DEBUGThat prints the full stack trace under the banner, and the top frame is either org.springframework.boot.context.properties.bind.validation.BindValidationException or org.springframework.boot.context.properties.bind.UnboundConfigurationPropertiesException. Those are two unrelated classes in two unrelated packages. The banner text happens to start the same way because both get wrapped in a BindException on the way out, and the Description template only bothers to say which target failed, not why, until the Reason: line three lines down.
A couple of Actuator endpoints help too, once the app is far enough along to expose them. For a startup failure it usually isn't, so these are more useful when you're checking the app before you change anything:
bash
curl -s localhost:8080/actuator/configprops | jq '."contexts"."repro"."beans"."mailProperties-com.example.repro.MailProperties"'
curl -s localhost:8080/actuator/env/app.mail.hostconfigprops shows you the values Boot actually bound. env shows you precedence: which property source won when the same key exists in two places. Neither helps once the context has failed to refresh, but they're the first thing I reach for when a value looks wrong and the app is up.
Break it yourself
What you need: JDK 21, Maven 3.9+, Spring Boot 4.1.1. No database, no Docker, no web starter even. This is a pure startup-time binding failure, so the app doesn't need to serve a single request.
Generate the project. start.spring.io wasn't reachable from where I was writing this, so here's the exact command and the dependency list it resolves to, side by side:
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 \
-d dependencies=validation \
-o repro.zip && unzip repro.zip -d repro && cd reprodependencies=validation alone is enough. spring-boot-starter-validation pulls in plain spring-boot-starter transitively, and that's the whole classpath this repro needs.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>The properties class. This is the one class every scenario below runs against. Nothing else changes except application.yml.
package com.example.repro;
import jakarta.annotation.PostConstruct;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
import org.springframework.validation.annotation.Validated;
@ConfigurationProperties(prefix = "app.mail", ignoreUnknownFields = false) // forcing lever for case 3
@Validated
public class MailProperties {
@NotBlank
private String host;
private int port = 25;
private int retries = 3;
public String getHost() {
return this.host;
}
public void setHost(String host) {
this.host = host;
}
public int getPort() {
return this.port;
}
public void setPort(int port) {
this.port = port;
}
public int getRetries() {
return this.retries;
}
public void setRetries(int retries) {
this.retries = retries;
}
@PostConstruct
void checkRetries() {
if (this.retries < 0 || this.retries > 10) {
throw new InvalidConfigurationPropertyValueException("app.mail.retries", this.retries,
"Must be between 0 and 10.");
}
}
}package com.example.repro;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class ReproApplication {
public static void main(String[] args) {
SpringApplication.run(ReproApplication.class, args);
}
}ignoreUnknownFields = false sits on the class the whole time. It only bites in step 3, but leaving it on doesn't interfere with the other three. It's off by default in every Spring Boot app you've ever run, which is exactly why most engineers have never seen the exception it enables.
Step 1: type mismatch.
app:
mail:
host: smtp.example.com
port: notanumber$ ./mvnw spring-boot:runExpected: dies in under 3 seconds. Description reads Failed to bind properties under 'app.mail.port' to java.lang.Integer:, Reason names a ConversionFailedException. No "Binding to target" text anywhere: this is BindFailureAnalyzer's own, separate banner.
Step 2: Bean Validation.
app:
mail:
host: ""
port: 25
retries: 3Expected: the second console block from the top of this article. Reason: must not be blank.
Step 3: unbound element (the typo).
app:
mail:
host: smtp.example.com
hots: smtp.example.com # typo, meant "host"
port: 25
retries: 3Expected: the first console block from the top. Reason: The elements [app.mail.hots] were left unbound. Note that app.mail.host is also unset now, but you never hear about that. Unbound-element checking doesn't know or care what your fields are called. It only tracks what showed up in a property source and was never claimed.
Step 4: value out of range.
app:
mail:
host: smtp.example.com
port: 25
retries: 99Expected:
Description:
Invalid value '99' for configuration property 'app.mail.retries' (originating from 'class path
resource [application.yml] - 4:14'). Validation failed for the following reason:
Must be between 0 and 10.
Action:
Review the value of the property with the provided reason.Completely different shape from the other three: plain prose, no Property:/Value:/Origin: labels at all, because InvalidConfigurationPropertyValueFailureAnalyzer builds its description as one paragraph instead of a labeled block.
Confirmation signal: all four exit 1 with no port bound (there's no web starter, so nothing was ever going to bind). The discriminator is entirely in the Description block: specifically the first line plus the Reason:. If you see Failed to bind properties under, you're in BindFailureAnalyzer's generic path, a type your converter couldn't handle. If you see Binding to target ... failed: with Reason: The elements [...] were left unbound., that's step 3. Same opening line with any other Reason text is step 2. And Invalid value 'X' for configuration property on its own, no Property/Value/Origin block, is step 4.
Teardown: ./mvnw clean, nothing else. No containers, no volumes, no lock files to worry about.
The one-paragraph version
Spring Boot builds a small pipeline of bind handlers around every @ConfigurationProperties class, and each handler is responsible for one failure mode: type conversion, unrecognized keys, Bean Validation. When any of them objects, the object it throws gets wrapped in a generic BindException on the way out of the binder, and a FailureAnalyzer unwraps it again to build the banner you actually read. Two of those analyzers happen to render their opening line from the same template, "Binding to target %s failed:%n", which is why the console can't tell you, in one glance, whether you're looking at a bad value or a bad key.
Under the hood
Every @ConfigurationProperties bean goes through ConfigurationPropertiesBinder, and the interesting part is how it decides which handlers to attach before it calls Binder.bind(). I read this straight from getBindHandler():
BindHandler handler = getHandler(); // base handler, tracks bound properties
handler = new ConfigurationPropertiesBindHandler(handler);
if (annotation.ignoreInvalidFields()) {
handler = new IgnoreErrorsBindHandler(handler);
}
if (!annotation.ignoreUnknownFields()) {
UnboundElementsSourceFilter filter = new UnboundElementsSourceFilter();
handler = new NoUnboundElementsBindHandler(handler, filter);
}
if (!validators.isEmpty()) {
handler = new ValidationBindHandler(handler, validators.toArray(new Validator[0]));
}Each if wraps the one before it. ignoreUnknownFields defaults to true on the @ConfigurationProperties annotation itself. I checked the annotation source: it's boolean ignoreUnknownFields() default true;. So NoUnboundElementsBindHandler isn't even in the chain unless you opt in. That's the whole reason UnboundConfigurationPropertiesException has such thin page-one coverage. Most people have genuinely never triggered it, because the handler that throws it doesn't exist in a default app.
NoUnboundElementsBindHandler does its check in onFinish, once binding for the whole tree completes, by diffing every key it saw against every key it successfully bound:
private void checkNoUnboundElements(ConfigurationPropertyName name, BindContext context) {
Set<ConfigurationProperty> unbound = new TreeSet<>();
for (ConfigurationPropertySource source : context.getSources()) {
if (source instanceof IterableConfigurationPropertySource && this.filter.apply(source)) {
collectUnbound(name, unbound, (IterableConfigurationPropertySource) source);
}
}
if (!unbound.isEmpty()) {
throw new UnboundConfigurationPropertiesException(unbound);
}
}ValidationBindHandler works completely differently. It runs your Bean Validation Validator (or a plain Spring Validator, or a self-validating object) against the fully-bound instance in onFinish too, and if there are errors it builds a BindValidationException and holds onto it until the top-level bind call finishes, at depth zero, then throws.
Here's the part I didn't expect going in. Both of those get thrown from inside Binder.bind(), which wraps any exception that escapes with a fresh BindException:
private <T> T handleBindError(ConfigurationPropertyName name, Bindable<T> target, BindHandler handler,
Context context, Exception error) {
try {
Object result = handler.onFailure(name, target, context, error);
return context.getConverter().convert(result, target);
}
catch (Exception ex) {
if (ex instanceof BindException bindException) {
throw bindException;
}
throw new BindException(name, target, context.getConfigurationProperty(), ex);
}
}So by the time FailureAnalyzers sees it, both BindValidationException and UnboundConfigurationPropertiesException are sitting as the cause of an outer BindException. BindFailureAnalyzer, the generic one, actually knows about this and steps aside on purpose:
protected FailureAnalysis analyze(Throwable rootFailure, BindException cause) {
Throwable rootCause = cause.getCause();
if (rootCause instanceof BindValidationException
|| rootCause instanceof UnboundConfigurationPropertiesException) {
return null; // defer to the specialist analyzer
}
return analyzeGenericBindException(rootFailure, cause);
}Return null here and FailureAnalyzers moves to the next analyzer in its list, BindValidationFailureAnalyzer or UnboundConfigurationPropertyFailureAnalyzer, and each of those independently builds a banner starting "Binding to target %s failed:%n". Neither one knows the other exists. Nobody coordinated the wording. It's the same phrase, written twice, by two classes that have never seen each other.
And here's the thing that made me go back and re-check my own repro. InvalidConfigurationPropertyValueException, the fourth exception, does not get this deferral treatment. BindFailureAnalyzer.analyze() only checks for the other two. So if you throw InvalidConfigurationPropertyValueException from inside a setter, during the bind call, it gets caught by that exact same handleBindError above, wrapped in a BindException, and BindFailureAnalyzer grabs it first. It's earlier in Boot's spring.factories list than InvalidConfigurationPropertyValueFailureAnalyzer, and analyzer dispatch is first-match-wins with no tie-break. You'd get the generic "Failed to bind properties under" banner, with your nicely-worded reason buried as a stack trace summary in the Reason: line instead of front and center. I moved the check in this repro from setRetries() into @PostConstruct specifically to avoid that. ConfigurationPropertiesBindingPostProcessor runs at Ordered.HIGHEST_PRECEDENCE + 1, well before @PostConstruct handling, so by the time checkRetries() runs, the binder has already returned and closed the BindException chapter. The exception now comes from ordinary bean initialization, wrapped directly in a BeanCreationException, and InvalidConfigurationPropertyValueFailureAnalyzer gets first look at it. That's the polished banner in step 4. Same exception class, same message, two different-looking failures, purely because of when in the bean lifecycle you throw it.
Telling the fix from the noise
For a type mismatch (step 1), there's nothing to fix in code. The value in configuration is wrong for the field's type. Check the Reason: line for what ConversionFailedException expected, and if the target is an enum, BindFailureAnalyzer prints the valid constants for you.
For Bean Validation (step 2), the fix is whatever the constraint says: supply a value, or relax the constraint if it was too strict for the environment. One thing I'd flag: don't silently loosen @NotBlank to make local dev easier. Use Spring profiles to supply a real default for dev instead. A validation constraint you routinely disable isn't a validation constraint.
# application-dev.yml
app:
mail:
host: localhostFor the unbound-element case (step 3), the honest fix is to correct the typo. But the design question is whether ignoreUnknownFields = false was worth turning on in the first place:
@ConfigurationProperties(prefix = "app.mail", ignoreUnknownFields = false)It costs you nothing in a correctly-configured app and catches exactly the class of bug that silently does nothing otherwise: a renamed property that half your config files still use the old name for. I'd turn it on for anything you own end to end. I wouldn't turn it on for a prefix that's shared with a third-party starter, because you don't control every key that might legitimately land under that prefix.
For InvalidConfigurationPropertyValueException (step 4), the fix is the value. But where you throw the check from is a real design decision, not a style preference. Throw it from a setter and you silently downgrade your own error message into the generic bind-failure banner, as shown above. Throw it from @PostConstruct, or from a dedicated @Bean-method validation step, and you get the banner the exception class was built to produce.
Named non-fixes. ignoreInvalidFields = true doesn't fix a validation failure. It makes the field silently keep its default value and logs nothing at all, which is worse than a loud failure, not better. Catching BeanCreationException in a @PostConstruct on some other bean and swallowing it only relocates the crash to whatever code path first tries to use the half-built properties object. And disabling spring-boot-starter-validation entirely to make step 2 "go away" doesn't remove the constraint: @Validated still runs if Bean Validation is present, so all you've done is remove the reason it's telling you.
Building it so this can't happen quietly
I'd bind configuration through records now, not JavaBean setters, wherever the shape allows it. Constructor binding gets you @NotNull for free on every parameter with no separate null check, and there's no setter to accidentally put a value check into:
@ConfigurationProperties(prefix = "app.mail", ignoreUnknownFields = false)
@Validated
public record MailProperties(@NotBlank String host, int port,
@Min(0) @Max(10) int retries) {
}That collapses step 2 and step 4 into a single mechanism, Bean Validation on the constructor parameters, and there's no @PostConstruct ordering question to get right, because the record can't exist in an invalid state in the first place. ignoreUnknownFields = false still belongs on the annotation regardless of binding style; it's a property-source concern, not a binding-style one.
For anything with genuine cross-field rules Bean Validation can't express cleanly (say, "retries only matters if port is set"), I'd keep a single @PostConstruct, or a dedicated Validator bean registered under the configurationPropertiesValidator name Boot looks for, rather than scattering checks across setters, so there's exactly one place in the codebase that decides what "invalid" means for this class.
Catching it before it reaches anyone
A context-loads test with the real production profile catches every one of these four before a deploy does. It's the cheapest test in the suite, and the one people skip because it only calls @SpringBootTest:
@SpringBootTest
@ActiveProfiles("prod")
class ReproApplicationTests {
@Test
void contextLoads() {
}
}Run it against whatever application-prod.yml actually ships, not the dev profile. A test that only loads the dev profile will never catch a prod-only misconfiguration.
For anything with a startup-relevant @ConfigurationProperties class, I'd also add a narrow unit test straight against the record or class's validation, no Spring context needed, using jakarta.validation.Validation.buildDefaultValidatorFactory() directly. It runs in milliseconds, and it's the fastest feedback loop of the three checks here.
On version safety: everything in this article (the annotation defaults, the handler chain order, the two identical banner openers, the BindFailureAnalyzer deferral logic) is unchanged between Spring Boot 3.5.16 and 4.1.1, and all four exception classes carry @since 2.0.0 in their javadoc. The one real difference I found: InvalidConfigurationPropertyValueException picked up a new constructor overload that accepts a cause in 4.1.0, so you can now chain the original exception through instead of losing it. If you're still on the 3.5 line, it went out of open-source support on 30 June 2026, so this is as good a reason as any to plan the move to 4.0 or 4.1.
If you remember nothing else
Binding to target X failed: can mean a rejected value or an unclaimed key. The Reason line is the only thing that tells them apart, not the headline. ignoreUnknownFields defaults to true, so most engineers have never seen UnboundConfigurationPropertiesException fire. Where you throw InvalidConfigurationPropertyValueException changes which banner you get: a setter buries it, @PostConstruct shows it properly. And ignoreInvalidFields = true isn't a gentler failure mode. It's silence.
Sources: Spring Boot Externalized Configuration reference; the ConfigurationPropertiesBinder, BindFailureAnalyzer, BindValidationFailureAnalyzer, UnboundConfigurationPropertyFailureAnalyzer, InvalidConfigurationPropertyValueFailureAnalyzer, NoUnboundElementsBindHandler and ValidationBindHandler source at spring-projects/spring-boot, tags v4.1.1 and v3.5.16. Related on this site: how Boot's FailureAnalyzer catalog and the APPLICATION FAILED TO START banner work in general, and the @Validated-on-a-controller mechanism, which is a completely different code path that happens to share an annotation name.