You have a @ConfigurationProperties class with a Duration timeout field, and in application.yaml you write this:
app:
timeout: 10
The application starts. You get no warning and no bind failure. Whoever wrote the YAML meant ten seconds, but Spring read it as ten milliseconds. The real timeout is a thousand times shorter than the team thinks, and startup gives no sign of it.
The YAML says 10 and toMillis() also says 10. Both are telling the truth. The mistake is the team's.
This post covers how Spring Boot 4.1 converts a Duration, what @DurationUnit changes, which formats stop startup, and which ones bind quietly with the wrong unit. Everything was checked in a Maven project with JUnit tests, and the results table is further down.
Versions and the three ways to write a Duration
The test project uses Spring Boot 4.1.1 (parent spring-boot-starter-parent:4.1.1), Temurin 25.0.4 with java.version 25 in the POM, and Maven 3.9.12. According to the Boot system requirements, 4.1.1 needs at least Java 17, works with versions up to Java 26, and needs Spring Framework 7.0.9 or later. JDK 25 is inside that range.
The Converting Durations section of the 4.1 docs accepts three formats for a Duration:
- A plain
long, which uses milliseconds as the default unit unless the field has@DurationUnit. - The ISO-8601 format that
java.time.Durationitself uses, such asPT10S. - A more readable format with the value and unit together, such as
10s. The accepted units arens,us,ms,s,m,handd.
The key sentence is in item one: with no unit, the value is in milliseconds. The docs example says 30, PT30S and 30s are equivalent, but the sessionTimeout field in that example has @DurationUnit(ChronoUnit.SECONDS). For a field without the annotation, the same page gives 500, PT0.5S and 500ms as equivalents. If you copy the 30-second example without the annotation, you keep the number and lose the unit.
The experiment classes
There are two properties classes. The first is the common case, a Duration with no annotation:
package academy.devdojo.durationbind;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app")
public class AppProperties {
private Duration timeout;
public Duration getTimeout() {
return this.timeout;
}
public void setTimeout(Duration timeout) {
this.timeout = timeout;
}
}
The second has the same type, plus @DurationUnit to change the default unit to seconds:
package academy.devdojo.durationbind;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
@ConfigurationProperties("app.seconds")
public class SecondsProperties {
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout;
public Duration getTimeout() {
return this.timeout;
}
public void setTimeout(Duration timeout) {
this.timeout = timeout;
}
}
The annotation's javadoc describes its value element as "the duration unit to use if one is not specified." Note the "if one is not specified" part: the annotation only applies when the value has no unit. We'll come back to that.
The tests use ApplicationContextRunner with withPropertyValues for the property cases. The runner has no withYaml, so the YAML cases start a real SpringApplication and point spring.config.location at a file in src/test/resources/yaml/. The main tests:
private final ApplicationContextRunner appRunner = new ApplicationContextRunner()
.withUserConfiguration(AppBindConfig.class);
private final ApplicationContextRunner secondsRunner = new ApplicationContextRunner()
.withUserConfiguration(SecondsBindConfig.class);
@Test
void suffixLessTenIsTenMillis() {
this.appRunner.withPropertyValues("app.timeout=10").run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(AppProperties.class).getTimeout().toMillis()).isEqualTo(10L);
});
}
@Test
void tenSecondsSuffixIsTenSeconds() {
this.appRunner.withPropertyValues("app.timeout=10s").run((context) -> {
assertThat(context).hasNotFailed();
Duration timeout = context.getBean(AppProperties.class).getTimeout();
assertThat(timeout.toSeconds()).isEqualTo(10L);
assertThat(timeout.toMillis()).isEqualTo(10_000L);
});
}
@Test
void durationUnitSecondsMakesSuffixLessTenTenSeconds() {
this.secondsRunner.withPropertyValues("app.seconds.timeout=10").run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(SecondsProperties.class).getTimeout().toSeconds()).isEqualTo(10L);
assertThat(context.getBean(SecondsProperties.class).getTimeout().toMillis()).isEqualTo(10_000L);
});
}
@Test
void simpleSuffixWinsOverDurationUnit() {
this.secondsRunner.withPropertyValues("app.seconds.timeout=10ms").run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(SecondsProperties.class).getTimeout().toMillis()).isEqualTo(10L);
});
}
@Test
void malformedSimpleUnitFailsRefresh() {
this.appRunner.withPropertyValues("app.timeout=10seconds")
.run((context) -> assertThat(context).hasFailed());
this.appRunner.withPropertyValues("app.timeout=10 sec")
.run((context) -> assertThat(context).hasFailed());
this.appRunner.withPropertyValues("app.timeout=foo")
.run((context) -> assertThat(context).hasFailed());
}
And here is the helper that starts the app with the real YAML:
private static void assertYamlMillis(String location, long expectedMillis) {
SpringApplication application = new SpringApplication(DurationBindApplication.class);
try (ConfigurableApplicationContext context = application.run("--spring.main.web-application-type=none",
"--spring.config.location=" + location)) {
assertThat(context.getBean(AppProperties.class).getTimeout().toMillis()).isEqualTo(expectedMillis);
}
}
In this post, "green" only means the context refresh finished without failing. The experiment has no HTTP server and no health check. That's enough to show the problem.
What mvn -q test showed
Running mvn -q test in the project directory gave 13 tests: 0 failures, 0 errors, 0 skipped. The Surefire report is TEST-academy.devdojo.durationbind.DurationBindTests.xml. These are the results:
| Input | Field | Context | Result |
|---|---|---|---|
app.timeout=10, YAML timeout: 10, YAML timeout: "10" | Duration, no annotation | starts | toMillis() == 10 |
app.timeout=10s, YAML timeout: 10s | Duration, no annotation | starts | toMillis() == 10000, toSeconds() == 10 |
app.timeout=PT10S, YAML timeout: PT10S | Duration, no annotation | starts | toMillis() == 10000, toSeconds() == 10 |
app.seconds.timeout=10 | @DurationUnit(ChronoUnit.SECONDS) | starts | toMillis() == 10000 |
app.seconds.timeout=10ms | @DurationUnit(ChronoUnit.SECONDS) | starts | toMillis() == 10 (the suffix wins) |
app.timeout=10seconds, 10 sec, foo | Duration, no annotation | refresh fails | hasFailed() |
YAML timeout: 10seconds | Duration, no annotation | SpringApplication does not start | FailureAnalysis on the app.timeout bind |
app.valid.nested.timeout=10 | nested @Valid with @NotNull Duration | starts | toMillis() == 10 |
app.valid.nested.timeout omitted | nested @Valid with @NotNull Duration | refresh fails | @NotNull violation |
The table shows four things.
No suffix means milliseconds, quoted or not. In YAML, timeout: 10 is an integer and timeout: "10" is a string, and the docs don't compare the two. In the experiment, both gave 10 ms. That matches the 4.1.1 source: NumberToDurationConverter calls toString() on the number and passes it to StringToDurationConverter, so both end up in the same parser.
@DurationUnit changes the default, not the rule. On the annotated field, 10 becomes 10 seconds, but 10ms on the same field is still 10 milliseconds. In 4.1.1's DurationStyle, when a simple-format value has a suffix, the unit comes from the suffix. The annotation's unit is only used when there's no suffix, and with no annotation the fallback is MILLIS. The ISO-8601 format ignores the annotation completely and calls Duration.parse.
A malformed unit stops startup. 10seconds, 10 sec and foo all fail the refresh, and DurationStyle explains why. The simple format follows the regex ^([+-]?\d+)([a-zA-Z]{0,2})$, which allows at most two suffix letters and no spaces. ISO-8601 has to start with P. None of the three inputs matches either format, so the parser throws IllegalArgumentException. The docs conversion page doesn't list these cases; this behavior comes from the source code and the tests.
For the YAML with timeout: 10seconds, the SpringApplication FailureAnalysis reports a failed bind for app.timeout, with Value "10seconds" and a Reason that includes '10seconds' is not a valid duration. Note the irony: the one version that tries to spell out the unit in full is the one that won't start.
The validator can't see units. With @Validated, @Valid on the nested object and @NotNull on the Duration, app.valid.nested.timeout=10 starts with 10 ms. That makes sense, because 10 milliseconds is not null. Only a missing value takes the context down. This isn't the nested @Valid on @ConfigurationProperties problem, where validation never fired. Here validation fires and passes, because the value bound correctly, just in the wrong unit. Adding @Positive won't help either: the Jakarta Validation 3.1 spec doesn't list Duration as a supported type for that constraint. Even if it did, 10 ms is positive.
Traps worth one sentence each
The habit comes from somewhere else. In Kubernetes, a probe's timeoutSeconds is an integer in seconds ("Number of seconds after which the probe times out," in the Pod API reference). If you just edited the manifest and then switch to application.yaml, you bring the 10 with you. The probe counts in seconds, Spring counts in milliseconds, and the YAML doesn't say which one applies. Spring doesn't follow the Kubernetes convention, and neither does Go: time.ParseDuration requires a unit suffix, so a bare number isn't even a valid duration there.
The suffix beats the annotation. This is good behavior, because a hand-written 10ms says exactly what it means. But someone who only reads the class and sees @DurationUnit(SECONDS) may assume every value in that field is in seconds. It isn't.
Malformed values fail loudly; wrong units fail silently. 10seconds stops the application from starting, so you find out at deploy time. 10 starts just fine, and you find out when the timeout fires a thousand times too early. Of the two mistakes, the noisy one is safer.
Switching from Duration to Long doesn't fix it. A long timeoutSeconds just moves the unit into the field name. You also lose the suffix, ISO-8601, and the bind failure on malformed values. The problem is still there, only harder to see.
This isn't property precedence. The value that reached the field is exactly what the YAML said, and no other source overrode it. The problem is entirely in the conversion.
What to do
The right advice depends on who writes the configuration.
- If your team writes the YAML by hand, always use an explicit unit:
10sorPT10S. Both gavetoMillis() == 10000in the experiment, and the next person to read the file won't have to open the Java class to find the unit. - If your team's convention (or a tool that generates the config) uses bare numbers in seconds, put
@DurationUnit(ChronoUnit.SECONDS)on the field. Then10means 10 seconds, and anyone who wants milliseconds can still write10ms. - Rethink that choice if the same property prefix ends up mixing annotated and unannotated fields. At that point, a bare
10in the YAML can mean seconds on one line and milliseconds on the next. Go back to requiring a suffix everywhere.
Either way, the next step is small. Write an ApplicationContextRunner test that binds the value your application actually uses and checks toMillis(). It looks just like suffixLessTenIsTenMillis above, with your real value in place of 10. A test assertion is the right place to find out your timeout is 10 ms. A production timeout log is too late.