> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Binding to target ... failed: two bugs, one identical banner
- URL: https://www.ggorantala.dev/spring-boot-binding-to-target-failed-two-bugs-one-banner/
- Published: 2026-10-02T05:23:13.000Z
- Updated: 2026-10-02T05:23:13.000Z
- Description: BindValidationException and UnboundConfigurationPropertiesException print the exact same Spring Boot startup banner. Here's how to tell which broke your app.
- Author: Gopi Gorantala
- Tags: spring-boot, spring-boot-4, spring-boot-errors, configurationproperties, bindvalidationexception, unboundconfigurationpropertiesexception, property-binding

## 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.

```log
***************************
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 configuration
```

That'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:

```log
***************************
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 configuration
```

Same 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:

```properties
logging.level.org.springframework.boot.diagnostics=DEBUG
```

That 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

```bash
curl -s localhost:8080/actuator/configprops | jq '."contexts"."repro"."beans"."mailProperties-com.example.repro.MailProperties"'
curl -s localhost:8080/actuator/env/app.mail.host
```

`configprops` 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:

```bash
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 repro
```

`dependencies=validation` alone is enough. `spring-boot-starter-validation` pulls in plain `spring-boot-starter` transitively, and that's the whole classpath this repro needs.

```xml
<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`.

```java
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.");
        }
    }

}
```

```java
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.**

```yaml
app:
  mail:
    host: smtp.example.com
    port: notanumber
```

```
$ ./mvnw spring-boot:run
```

Expected: 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.**

```yaml
app:
  mail:
    host: ""
    port: 25
    retries: 3
```

Expected: the second console block from the top of this article. `Reason: must not be blank`.

**Step 3: unbound element (the typo).**

```yaml
app:
  mail:
    host: smtp.example.com
    hots: smtp.example.com   # typo, meant "host"
    port: 25
    retries: 3
```

Expected: 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.**

```yaml
app:
  mail:
    host: smtp.example.com
    port: 25
    retries: 99
```

Expected:

```log
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()`:

```java
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:

```java
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`:

```java
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:

```java
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.

```yaml
# application-dev.yml
app:
  mail:
    host: localhost
```

For 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:

```java
@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:

```java
@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`:

```java
@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](https://docs.spring.io/spring-boot/reference/features/external-config.html); the `ConfigurationPropertiesBinder`, `BindFailureAnalyzer`, `BindValidationFailureAnalyzer`, `UnboundConfigurationPropertyFailureAnalyzer`, `InvalidConfigurationPropertyValueFailureAnalyzer`, `NoUnboundElementsBindHandler` and `ValidationBindHandler` source at [spring-projects/spring-boot](https://github.com/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.