Command Palette

Search for a command to run...

[Spring Boot Basics] application.properties vs application.yml in Spring Boot: Syntax and @Value

Every project Spring Initializr generates ships with src/main/resources/application.properties holding exactly one line, spring.application.name=demo. This series has already written into that file three times — a port, lazy initialization, an auto-configuration exclusion — without saying anything about the file itself. This article is about the file: what the two formats Spring Boot reads actually accept, where each one quietly does something you did not ask for, and how a value travels from a line of text into a constructor parameter.

The syntax looks too simple to need an article, and that is exactly the problem. A .properties file is decoded as ISO-8859-1, so Xin chào arrives as Xin chà o. YAML turns 0123 into 83 and on into true without a warning. One @Value without a default keeps the application from starting. All of it was run, and the output below is quoted as it came out.

A flat .properties file and a nested .yml file feeding the same @Value

Everything below was produced on OpenJDK 21.0.6 with Spring Boot 4.1.1 (Spring Framework 7.0.9, SnakeYAML 2.6) and Gradle 9.7.1, using a project generated by Spring Initializr with dependencies=web. Every parsed value, conversion result and error message is copied from a run of that project.

Where Spring Boot reads application.properties and application.yml from

Everything in src/main/resources is copied to the root of the classpath: build/resources/main while you develop, BOOT-INF/classes/ inside the jar. At startup Boot looks there for application.properties, application.yml and application.yaml, parses every one it finds into a property source, and adds those property sources to the Environment. @Value, Environment.getProperty() and everything else that reads configuration read from there.

A property source is a flat map with String keys, and that is the most useful fact in this article: whatever the file looked like, the application sees a list of dotted keys. The quickest way to see it is to print the map. This runner walks the Environment and dumps every property source that was loaded from a configuration file:

ConfigFileDump.java
@Component
public class ConfigFileDump implements ApplicationRunner {
 
    private final ConfigurableEnvironment environment;
 
    public ConfigFileDump(ConfigurableEnvironment environment) {
        this.environment = environment;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        for (PropertySource<?> source : environment.getPropertySources()) {
            if (source instanceof OriginTrackedMapPropertySource file) {
                System.out.println(file.getName());
                for (String key : file.getPropertyNames()) {
                    Object value = file.getProperty(key);
                    System.out.println("  " + key + " = " + value + " (" + value.getClass().getSimpleName() + ")");
                }
            }
        }
    }
}

Boot uses the same class, OriginTrackedMapPropertySource, for both formats. Every dump in the rest of this article comes from this runner, or from reading single keys with Environment.getProperty().

application.properties syntax

For .properties files Boot does not use java.util.Properties: PropertiesPropertySourceLoader hands the file to Boot's own OriginTrackedPropertiesLoader. The rules below were therefore checked against Boot itself — each line was written into application.properties and read back — rather than copied from the Properties Javadoc.

Separators: =, : and whitespace

application.properties
app.eq=equals sign
app.colon: colon separator
app.space whitespace separator
app.spaced   =   around the separator
    app.indented=leading spaces before the key

With three spaces added after around the separator, this is what Boot stored:

LineKeyValue
app.eq=equals signapp.eqequals sign
app.colon: colon separatorapp.coloncolon separator
app.space whitespace separatorapp.spacewhitespace separator
app.spaced = around the separatorapp.spacedaround the separator plus the three trailing spaces — 23 characters
app.indented=leading spaces before the keyapp.indentedleading spaces before the key

The key ends at the first unescaped =, : or whitespace character. Whitespace before the key and on both sides of the separator is dropped; whitespace at the end of the value is kept. A stray space after a password or a URL becomes part of it, and nothing warns you.

Comments, line continuation and duplicate keys

application.properties
# a comment starting with a hash
! a comment starting with an exclamation mark
app.hash=value # not a comment
app.continued=first,\
              second,\
              third
app.empty=
app.dup=first
app.dup=second
  • A line whose first non-blank character is # or ! is a comment and produces no key.
  • app.hash is value # not a comment. A # in the middle of a line is part of the value — YAML does the opposite, as you will see below.
  • app.continued is first,second,third. A backslash at the end of a line joins the next line, and the leading whitespace of that line is dropped, so continuation lines can be indented freely.
  • app.empty exists and holds an empty string. It is not a missing key, which matters once defaults come into play.
  • app.dup is second. The last definition wins, silently.

Escapes, backslashes and Windows paths

application.properties
app.tab=a\tb
app.newline=line1\nline2
app.unicode=Xin ch\u00e0o
app.backslash=C:\\temp\\new
app.single-backslash=C:\temp\new
app.key\ with\ spaces=ok
app.equals\=in\:key=ok
app.value-with-equals=a=b
app.value-with-colon=http://localhost:8080
LineWhat Boot stored
app.tab=a\tba, a TAB character, b
app.newline=line1\nline2line1, a newline, line2
app.unicode=Xin ch\u00e0oXin chào
app.backslash=C:\\temp\\newC:\temp\new
app.single-backslash=C:\temp\newC:, TAB, emp, newline, ew — 9 characters
app.key\ with\ spaces=okthe key app.key with spaces
app.equals\=in\:key=okthe key app.equals=in:key
app.value-with-equals=a=bthe value a=b
app.value-with-colon=http://localhost:8080the value http://localhost:8080

The Windows path is the one that hurts in practice: \t and \n are escapes, so C:\temp\new is loaded as a tab and a newline without any error. Double every backslash. Only a separator character inside the key needs escaping; = and : inside a value are taken literally.

Lists: comma-separated values and indexed keys

application.properties
app.names=alice,bob,carol
app.servers[0]=alpha
app.servers[1]=beta

These are two different things. app.names is one key holding one string; it becomes a list only when something converts it, and @Value into a List<String> does, as shown further down. app.servers[0] and app.servers[1] are two separate keys, and there is no key named app.servers: getProperty("app.servers") returns null. Indexed keys matter because they are exactly what YAML lists turn into.

Vietnamese text and the ISO-8859-1 default

Put a Vietnamese value into the generated file. Any modern editor saves it as UTF-8, and file agrees:

application.properties
app.greeting=Xin chào
Text
application.properties: Unicode text, UTF-8 text

Read it back and print the string, its length and its code points:

Java
String greeting = environment.getProperty("app.greeting");
System.out.println(greeting + " | length " + greeting.length());
greeting.chars().forEach(c -> System.out.printf("U+%04X ", c));
Text
Xin chà o | length 9
U+0058 U+0069 U+006E U+0020 U+0063 U+0068 U+00C3 U+00A0 U+006F

à is two bytes in UTF-8, C3 A0. Boot's properties loader decodes the file as ISO-8859-1 unless it is given another charset — the reader inside OriginTrackedPropertiesLoader falls back to StandardCharsets.ISO_8859_1 when no charset is passed — so the two bytes become two characters: Ã and a non-breaking space. @Value("${app.greeting}") injects the same nine characters.

Nothing else in the chain causes it, and nothing hides it. Gradle's processResources copies the file byte for byte — cmp reports the source file, build/resources/main/application.properties and the entry inside the jar as identical — java -jar prints the same mangled string, and the console is not at fault either: stdout.encoding is UTF-8 in this run. The file looks correct in the editor because its bytes are correct; only the decoding is wrong.

The same three lines of code, run against each fix:

What is in the filePrinted
app.greeting=Xin chào in application.propertiesXin chà o | length 9
app.greeting=Xin ch\u00e0o in application.propertiesXin chào | length 8
greeting: Xin chào under app: in application.ymlXin chào | length 8
spring.config.import=classpath:greeting.properties[encoding=utf-8], with the value in greeting.propertiesXin chào | length 8
the same import without [encoding=utf-8]Xin chà o | length 9

Unicode escapes work, but nobody can read or review them. YAML is read as UTF-8, which makes it the simplest choice for configuration that carries Vietnamese text. The [encoding=utf-8] hint applies to a file you import; config imports themselves are covered later in the series.

application.yml syntax

YAML writes the same key-value pairs as a tree. Indentation — spaces only — decides what belongs to what. A key followed by a colon and a space holds a value; a key followed by a colon and nothing else is the parent of the indented lines below it.

Nesting, lists and maps

application.yml
# the same configuration, written as YAML
app:
  name: Demo Shop
  owner:
    name: Hoang
    email: hoang@example.com
  servers:
    - alpha
    - beta
  regions: [eu-west, ap-southeast]
  limits: {requests: 100, burst: 20}

Four shapes are in that file. app and owner are maps written as indented blocks; servers is a block list, one - item per line; regions is a flow list in square brackets; limits is a flow map in braces. Block and flow forms produce exactly the same kind of keys, as the dump below shows. The line starting with # is a comment.

Multi-line strings, quoting and comments

application.yml
app:
  banner: |
    Welcome to Demo Shop
    Have a nice day
  description: >
    A folded string
    joins lines with spaces
  literal-strip: |-
    no trailing
    newline
  single: 'it''s ${app.name}'
  double: "tab\there, accent \u00e0"
  plain-hash: value # a comment

What Boot stored, written as Java string literals so the newlines are visible:

KeyStored valueRule
app.banner"Welcome to Demo Shop\nHave a nice day\n"| keeps every line break, including the last one
app.description"A folded string joins lines with spaces\n"> folds line breaks into spaces and keeps the last one
app.literal-strip"no trailing\nnewline"|- keeps the breaks and strips the final newline
app.single"it's ${app.name}"single quotes: no escapes, '' is one quote
app.double"tab\there, accent à"double quotes process \t, \u00e0 and the other escapes
app.plain-hash"value"a # after a space starts a comment

The trailing newline of | is a real character; if the value is a token or anything that gets compared, use |-. And quoting does not stop Spring's placeholder resolution: getProperty("app.single") returns it's Demo Shop. Quotes are a YAML concern, while ${...} is resolved later by Spring, on the already-parsed string.

YAML is flattened into exactly the same keys

The dumper, run against the YAML file from the nesting example — the first seven entries:

Text
Config resource 'class path resource [application.yml]' via location 'optional:classpath:/'
  app.name = Demo Shop (String)
  app.owner.name = Hoang (String)
  app.owner.email = hoang@example.com (String)
  app.servers[0] = alpha (String)
  app.servers[1] = beta (String)
  app.regions[0] = eu-west (String)
  app.regions[1] = ap-southeast (String)

Now delete application.yml and write the same data as .properties:

application.properties
app.name=Demo Shop
app.owner.name=Hoang
app.owner.email=hoang@example.com
app.servers[0]=alpha
app.servers[1]=beta
app.regions[0]=eu-west
app.regions[1]=ap-southeast
app.limits.requests=100
app.limits.burst=20
Text
Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'
  app.name = Demo Shop (String)
  app.owner.name = Hoang (String)
  app.owner.email = hoang@example.com (String)
  app.servers[0] = alpha (String)
  app.servers[1] = beta (String)
  app.regions[0] = eu-west (String)
  app.regions[1] = ap-southeast (String)

The same keys, in the same order. getProperty("app.owner.email") returns hoang@example.com from either file, getProperty("app.limits.burst") returns 20 from either, and getProperty("app.servers") returns null from both — a list is never a key of its own; only its elements are. Past the seven lines shown, the full dumps differ in one detail: YAML's 100 is stored as an Integer, the .properties 100 as a String, and the difference is gone as soon as the value is read as a string.

The same configuration as flat .properties lines and as a nested YAML tree, each leaf mapping to the identical Environment key

YAML traps that stop startup or silently change values

YAML's mistakes come in two kinds. The loud kind stops the application at startup with a SnakeYAML message. The quiet kind parses fine and hands you a different value from the one you wrote.

Tabs, unquoted colons and duplicate keys stop the application

Indent one line with a tab:

Text
14:44:47.209 [main] ERROR org.springframework.boot.SpringApplication -- Application run failed
while scanning for the next token
found character '\t(TAB)' that cannot start any token. (Do not use \t(TAB) for indentation)
 in 'reader', line 2, column 1:
    	name: Demo Shop
    ^

Write a colon followed by a space inside an unquoted value:

application.yml
app:
  message: Note: read this first
Text
14:44:48.005 [main] ERROR org.springframework.boot.SpringApplication -- Application run failed
mapping values are not allowed here
 in 'reader', line 2, column 16:
      message: Note: read this first
                   ^

A colon with no space after it is harmless, which is why url: http://localhost:8080 works without quotes. Quote the value — message: "Note: read this first" loads as Note: read this first — whenever it contains : or #.

Define the same key twice in one map:

application.yml
app:
  name: Demo Shop
  name: Another Shop
Text
while constructing a mapping
 in 'reader', line 2, column 3:
      name: Demo Shop
      ^
found duplicate key name
 in 'reader', line 3, column 3:
      name: Another Shop
      ^

Boot configures SnakeYAML to reject duplicate keys. Plain SnakeYAML 2.6 with default options only logs WARNING: duplicate keys found and carries on, and a .properties file keeps the last value without a word. The same error appears when two keys are different in the file but equal after type resolution: on: and yes: in one map both become the boolean key true and fail with found duplicate key true.

Values that silently change type

This file parses without an error:

application.yml
app:
  feature-enabled: on
  legacy-mode: off
  confirm-orders: yes
  country: NO
  pin: 0123
  release: 1.10
  backup-time: 12:30
  launch-date: 2026-10-10
  theme-color: #fff
  owner: ~
  pin-quoted: "0123"
  release-quoted: "1.10"
Text
Config resource 'class path resource [application.yml]' via location 'optional:classpath:/'
  app.feature-enabled = true (Boolean)
  app.legacy-mode = false (Boolean)
  app.confirm-orders = true (Boolean)
  app.country = false (Boolean)
  app.pin = 83 (Integer)
  app.release = 1.1 (Double)
  app.backup-time = 750 (Integer)
  app.launch-date = 2026-10-10 (String)
  app.theme-color =  (String)
  app.owner =  (String)
  app.pin-quoted = 0123 (String)
  app.release-quoted = 1.10 (String)

Four booleans nobody asked for, a PIN that lost its leading zero and became 83, a release that is now 1.1, a backup time that is now the integer 750 and a colour that is now an empty string. Only the quoted values survived as written.

Here is every value tested, with the type SnakeYAML 2.6 produced and what Environment.getProperty returns in Boot 4.1.1. The last column is the same value parsed by a YAML 1.2 parser — the JavaScript yaml 2.8.3 package in its default YAML 1.2 core schema — because much of the advice about these traps is written against one version of the spec and applied to the other:

Unquoted valueSnakeYAML 2.6 typeBoot getPropertyYAML 1.2 core schema
onBooleantruestring on
offBooleanfalsestring off
yesBooleantruestring yes
no, NOBooleanfalsestring no, NO
TrueBooleantrueboolean true
yStringystring y
0123Integer, read as octal83number 123
0189String, not valid octal0189number 189
0o14String0o14number 12
0x1FInteger31number 31
1_000Integer1000string 1_000
1.10Double1.1number 1.1
1.2.3String1.2.3string 1.2.3
1e3Double1000.0number 1000
12:30Integer, base 60750string 12:30
1:30:00Integer, base 605400string 1:30:00
2026-10-10Date in plain SnakeYAML, String in Boot2026-10-10string 2026-10-10
null, ~ or nothingnullempty stringnull
#fffa comment, so nullempty stringnull
"0123", "on"String0123, onstring

Three conclusions from that table:

  1. SnakeYAML 2.6 is a YAML 1.1 parser, and so every Spring Boot application that reads application.yml follows YAML 1.1 rules. The YAML 1.2 changes — on, yes and no as plain strings, no base-60 numbers — do not apply. Boot adds two changes of its own: dates stay strings, because its loader removes SnakeYAML's timestamp resolver (OriginTrackedYamlLoader.NoTimestampResolver), and null becomes an empty string.
  2. Some of the folklore is wrong in the other direction. y belongs to the YAML 1.1 boolean type, and yaml 2.8.3 in 1.1 mode does turn it into true, but SnakeYAML 2.6 leaves y a string. And YAML 1.2 would not save you from everything: 0123 still loses its zero there, and 1.10 is still 1.1.
  3. The damage lands on strings, not on booleans. Read on into a boolean and either format gives trueapp.flag=on in .properties converts to true as well. Read the same key into a String and YAML gives "true" while .properties gives "on". A country code, a PIN, a version and a time of day are all strings, and those are the values that get mangled.

Keys are resolved too. In the test file, on: and 0123: under a map named trap.keys were stored as the keys trap.keys[true] and trap.keys[83].

⚠️ Quote every YAML value that is meant as text and could pass for a boolean, a number, a time or a null: "NO", "0123", "1.10", "12:30", "#fff". Quoting a value that really is a number costs nothing, because @Value converts the string "8091" into an int just the same.

What happens when both application.properties and application.yml exist?

Both files are loaded, and for a key defined in both, application.properties wins. With the two files side by side in src/main/resources:

KeyIn .propertiesIn .ymlgetProperty returns
app.sourcefrom application.propertiesfrom application.ymlfrom application.properties
app.only-in-propertiespp
app.only-in-yamlyy

The files are merged key by key rather than chosen as a whole: a key that exists in only one of them is still read from it. Where else configuration can come from, and the full order between all of those places, is a later article's subject; for two files in the same directory, this is the only rule you need.

Placeholders inside configuration files

A value can refer to other keys with ${...}. The property source stores the text exactly as written, and the reference is resolved each time the value is read.

application.properties
app.host=localhost
app.port=8091
app.url=http://${app.host}:${app.port}
app.timeout=${app.connect-timeout:30s}
app.instance-id=${random.uuid}
app.shard=${random.int(1,10)}
KeyStored in the property sourcegetProperty returns
app.urlhttp://${app.host}:${app.port}http://localhost:8091
app.timeout${app.connect-timeout:30s}30s — there is no app.connect-timeout, so the text after the first : is used
app.instance-id${random.uuid}ca97c7cf-f034-4bde-8066-ac2bba99a24a, then d39d8360-189f-4186-a351-02cd34018e29 on the next call
app.shard${random.int(1,10)}6, 7 and 3 on three calls

Three details are easy to get wrong:

  • ${random.*} is evaluated again on every read. Two getProperty("app.instance-id") calls return two different UUIDs, and two fields annotated @Value("${app.instance-id}") receive two different UUIDs as well. If an instance id must stay the same for the life of the process, read it once and keep it in a bean.
  • random.int(1,10) excludes the upper bound. 100,000 reads produced exactly the values 1 through 9.
  • A missing key inside a value fails when the value is read, not at startup. With app.broken-url=http://${app.nope}/x the application starts normally, and the first getProperty("app.broken-url") throws:
Text
org.springframework.util.PlaceholderResolutionException: Could not resolve placeholder 'app.nope' in value "http://${app.nope}/x"

Placeholders behave the same in YAML, where url: http://${app.host}:${app.port} needs no quotes. To keep a literal ${ in a value, put a backslash in front of the dollar sign — Spring's placeholder syntax uses \ as its escape character. The .properties parser consumes one backslash of its own before Spring sees the value, so there it takes two:

FileWrittengetProperty returns
.propertiesapp.dollar=\${literal}throws Could not resolve placeholder 'literal' in value "${literal}"
.propertiesapp.escaped-placeholder=\\${literal}${literal}
.yml, plain or single-quotedplain: \${not.a.placeholder}${not.a.placeholder}
.yml, double-quoteddouble: "\\${not.a.placeholder}"${not.a.placeholder}

How to read a property with @Value

@Value injects a resolved configuration value into a bean while the bean is being created. It goes on a field, a setter or a constructor parameter:

FieldStyle.java
@Component
public class FieldStyle {
 
    @Value("${app.name}")
    private String name;
}
SetterStyle.java
@Component
public class SetterStyle {
 
    private String name;
 
    @Value("${app.name}")
    public void setName(String name) {
        this.name = name;
    }
}

Both print Demo Shop. Prefer the constructor, for the same reason as with any other dependency: the field can be final, and a test can pass the value straight in. Here is a constructor that takes one value of each common type, against this configuration:

application.properties
app.name=Demo Shop
app.host=localhost
app.port=8091
app.url=http://${app.host}:${app.port}
app.secure=true
app.names=alice,bob,carol
AppSettings.java
@Component
public class AppSettings {
 
    private final String url;
    private final int port;
    private final boolean secure;
    private final Duration timeout;
    private final List<String> names;
    private final String[] nameArray;
    private final int[] retryDelays;
 
    public AppSettings(@Value("${app.url}") String url,
                       @Value("${app.port}") int port,
                       @Value("${app.secure:false}") boolean secure,
                       @Value("${app.timeout:30s}") Duration timeout,
                       @Value("${app.names}") List<String> names,
                       @Value("${app.names}") String[] nameArray,
                       @Value("${app.retry-delays:100,200,400}") int[] retryDelays) {
        this.url = url;
        this.port = port;
        this.secure = secure;
        this.timeout = timeout;
        this.names = names;
        this.nameArray = nameArray;
        this.retryDelays = retryDelays;
    }
}

Printed from the bean once the application has started:

Text
url         = http://localhost:8091
port        = 8091 (int)
secure      = true (boolean)
timeout     = PT30S (Duration)
names       = [alice, bob, carol] (ArrayList, size 3)
nameArray   = [alice, bob, carol] (length 3)
retryDelays = [100, 200, 400] (int[])

The ordered steps behind @Value with a placeholder and a default, and the two ways it fails at startup

Every @Value goes through the same steps, in the order DefaultListableBeanFactory.doResolveDependency runs them in Spring Framework 7.0.9. The annotation's string is read; placeholders are resolved against the Environment, falling back to the default after the first : when a key is absent; any #{...} expression is evaluated; and the resulting string is converted to the parameter's type. The next sections take those steps one at a time.

Default values with ${key:default}

The default is everything after the first colon inside the braces, and it is used only when the key is absent. @Value("${app.timeout:30s}") Duration timeout injects PT30S with no app.timeout in the file, and PT45S after adding app.timeout=45s.

Because only the first colon separates the key from the default, a default can contain colons of its own, and it can be another placeholder:

@Value(...)Injected
"${app.fallback-url:http://localhost:8080/api}"http://localhost:8080/api
"${app.primary:${app.secondary:nested-default}}"nested-default
"app.name"app.name — without ${} it is a literal string, not a lookup

The last row compiles, starts and injects the key's own name. It is the easiest @Value mistake to make and the hardest to notice.

What happens when a key is missing and there is no default?

ApiClient.java
@Component
public class ApiClient {
 
    private final String apiKey;
 
    public ApiClient(@Value("${app.api-key}") String apiKey) {
        this.apiKey = apiKey;
    }
}

With no app.api-key anywhere, the application does not start:

Text
2026-09-11T14:45:08.369+07:00  WARN 47311 --- [demo] [           main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'apiClient' defined in file [/…/demo/build/classes/java/main/com/example/demo/ApiClient.class]: Unexpected exception during bean creation
2026-09-11T14:45:08.370+07:00  INFO 47311 --- [demo] [           main] o.apache.catalina.core.StandardService   : Stopping service [Tomcat]
2026-09-11T14:45:08.374+07:00  INFO 47311 --- [demo] [           main] .s.b.a.l.ConditionEvaluationReportLogger :
 
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-09-11T14:45:08.378+07:00 ERROR 47311 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
 
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'apiClient' defined in file [/…/demo/build/classes/java/main/com/example/demo/ApiClient.class]: Unexpected exception during bean creation
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:538) ~[spring-beans-7.0.9.jar:7.0.9]
	...
Caused by: org.springframework.util.PlaceholderResolutionException: Could not resolve placeholder 'app.api-key' in value "${app.api-key}"
	at org.springframework.util.PlaceholderResolutionException.withValue(PlaceholderResolutionException.java:81) ~[spring-core-7.0.9.jar:7.0.9]
	at org.springframework.util.PlaceholderParser$ParsedValue.resolve(PlaceholderParser.java:296) ~[spring-core-7.0.9.jar:7.0.9]
	at org.springframework.util.PlaceholderParser.replacePlaceholders(PlaceholderParser.java:129) ~[spring-core-7.0.9.jar:7.0.9]
	at org.springframework.util.PropertyPlaceholderHelper.replacePlaceholders(PropertyPlaceholderHelper.java:96) ~[spring-core-7.0.9.jar:7.0.9]
	at org.springframework.core.env.AbstractPropertyResolver.doResolvePlaceholders(AbstractPropertyResolver.java:286) ~[spring-core-7.0.9.jar:7.0.9]
	at org.springframework.core.env.AbstractPropertyResolver.resolveRequiredPlaceholders(AbstractPropertyResolver.java:257) ~[spring-core-7.0.9.jar:7.0.9]
	at org.springframework.context.support.PropertySourcesPlaceholderConfigurer.lambda$processProperties$0(PropertySourcesPlaceholderConfigurer.java:184) ~[spring-context-7.0.9.jar:7.0.9]
	at org.springframework.beans.factory.support.AbstractBeanFactory.resolveEmbeddedValue(AbstractBeanFactory.java:959) ~[spring-beans-7.0.9.jar:7.0.9]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1679) ~[spring-beans-7.0.9.jar:7.0.9]
	...

No failure analyzer handles this exception, so there is no "APPLICATION FAILED TO START" box with a description and an action — read the last Caused by: line instead. The frames under it are the lookup step from the diagram: doResolveDependency calls resolveEmbeddedValue, which goes through PropertySourcesPlaceholderConfigurer into resolveRequiredPlaceholders, finds no value and has no default to fall back on. Tomcat is stopped and the process exits.

The same exception is what @Value("${app.servers}") List<String> produces when app.servers is a YAML list — Could not resolve placeholder 'app.servers' in value "${app.servers}" — because only app.servers[0] and app.servers[1] exist. @Value("${app.servers[0]}") works.

The empty default ${key:}

A colon with nothing after it makes a value optional, with a result that depends on the target type:

Target type@Value("${missing.key:}") injects
Stringan empty string
List<String>an empty ArrayList
String[]an empty array
Integer, Boolean, Durationnull
intnothing — startup fails with Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: ""

An empty default on a primitive is a startup failure dressed up as an optional value. Use the wrapper type, or a real default.

Type conversion: int, boolean, Duration, List and arrays

Every string @Value produces is converted by the bean factory's conversion service, and in a Boot application that is org.springframework.boot.convert.ApplicationConversionService. Each of these was injected into a constructor parameter or a field:

Value in the fileTarget typeInjected
8091int8091
truebooleantrue
onbooleantrue; into a String it stays on
30sDurationPT30S
30DurationPT0.03S — a bare number means milliseconds
alice,bob,carolList<String>[alice, bob, carol], an ArrayList of size 3
alice, bob , carolList<String>[alice, bob, carol] — every element is trimmed
alice,bob,carolString[][alice, bob, carol], length 3
100,200,400int[][100, 200, 400]
-5int-5
eightyintstartup failure, below

A value that cannot be converted fails the startup at the bean that asked for it. Here is app.port=eighty against AppSettings:

Text
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'appSettings' defined in file [/…/demo/build/classes/java/main/com/example/demo/AppSettings.class]: Unsatisfied dependency expressed through constructor parameter 1: Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "eighty"
	...
Caused by: org.springframework.beans.TypeMismatchException: Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "eighty"
	...
Caused by: java.lang.NumberFormatException: For input string: "eighty"

constructor parameter 1 counts from zero: it is port, the second parameter.

${...} vs #{...}: property placeholders and SpEL

${...} is a property placeholder. The text inside is a key, with an optional default, looked up in the Environment, and the placeholder is replaced by the value it finds. It is text substitution and nothing more. #{...} is a SpEL expression — Spring Expression Language. The text inside is parsed as code and evaluated: operators, method calls, T(...) type references, the systemProperties and environment variables.

Against app.port=8091, app.host=localhost and app.names=alice,bob,carol:

@Value(...)Injected
"${app.port} + 1"8091 + 1, a String
"#{${app.port} + 1}"8092
"#{'${app.host}'.toUpperCase()}"LOCALHOST
"#{systemProperties['java.version']}"21.0.6
"#{environment['app.host']}"localhost
"#{environment['app.missing'] ?: 'elvis-default'}"elvis-default
"#{T(java.lang.Math).max(${app.min-workers:2}, 4)}"4
"#{'${app.names}'.split(',')}" into a List<String>[alice, bob, carol]
"#{'${app.names}'.split(',').length}" into an int3

The first row is the one to remember: ${app.port} + 1 does no arithmetic; it produces the string 8091 + 1. Arithmetic, method calls and values computed from other values need #{...}, usually with a ${...} inside it.

Which one runs first?

Placeholders, always. In DefaultListableBeanFactory.doResolveDependency the call to resolveEmbeddedValue comes before evaluateBeanDefinitionString, and three runs show the consequences without reading any bytecode.

The placeholder writes the expression. With app.expr=6*7, @Value("#{${app.expr}}") int injects 42. The placeholder put 6*7 into the expression before SpEL parsed it; @Value("${app.expr}") String on its own is just 6*7.

SpEL only sees the substituted text. Leave out the quotes around the placeholder:

Java
public SpelBroken(@Value("#{${app.names}.split(',')}") List<String> names) {
Text
Caused by: org.springframework.expression.spel.SpelParseException: Expression [alice,bob,carol.split(',')] @5: EL1041E: After parsing a valid expression, there is still more data in the expression: 'comma(,)'

The expression in the error is alice,bob,carol.split(',')${app.names} had already been replaced. SpEL parsed alice as a complete expression and then ran into a comma. The single quotes in #{'${app.names}'.split(',')} are what turn the substituted text into a SpEL string literal.

A value from the file is evaluated too. With app.computed=#{2*21}, @Value("${app.computed}") int injects 42: the placeholder resolved to #{2*21}, and the expression step then evaluated it. environment.getProperty("app.computed") returns the text #{2*21} untouched. Anything that reaches @Value through a placeholder can therefore run as an expression, which is a reason to care where configuration values come from.

One behavioural difference follows from all of this. app.names-spaced=alice, bob , carol injected with @Value("${app.names-spaced}") List<String> gives [alice, bob, carol], because the conversion service trims each element. Through #{'${app.names-spaced}'.split(',')} it gives [alice, bob , carol], and the second element is bob with both spaces — String.split trims nothing.

Placeholder resolution against the Environment versus SpEL evaluation, and the two passes applied to a combined expression

Reading properties through the Environment

@Value fixes the key at compile time. When the key is only known at run time, or the code needs to branch on whether a key exists, inject the Environment and ask it directly. In a Boot application the injected object is an org.springframework.boot.ApplicationEnvironment.

EnvironmentDemo.java
@Component
public class EnvironmentDemo implements ApplicationRunner {
 
    private final Environment environment;
 
    public EnvironmentDemo(Environment environment) {
        this.environment = environment;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        System.out.println(environment.getProperty("app.host"));
        System.out.println(environment.getProperty("app.api-key"));
        System.out.println(environment.getProperty("app.api-key", "not-set"));
        System.out.println(environment.getProperty("app.port", Integer.class));
        System.out.println(environment.getProperty("app.max-connections", Integer.class, 0));
        System.out.println(environment.getProperty("app.timeout", Duration.class, Duration.ofSeconds(30)));
        System.out.println(environment.getProperty("app.url"));
        System.out.println(environment.containsProperty("app.host"));
        System.out.println(environment.getRequiredProperty("app.api-key"));
    }
}
Text
localhost
null
not-set
8091
0
PT30S
http://localhost:8091
true
2026-09-11T15:06:33.604+07:00  INFO 68821 --- [demo] [           main] .s.b.a.l.ConditionEvaluationReportLogger :
 
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-09-11T15:06:33.607+07:00 ERROR 68821 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
 
java.lang.IllegalStateException: Required key 'app.api-key' not found
CallReturns
getProperty("app.host")the resolved value as a String
getProperty("app.api-key")null for a missing key — it never throws because a key is absent
getProperty("app.api-key", "not-set")the default for a missing key
getProperty("app.port", Integer.class)the value converted to the requested type
getProperty("app.max-connections", Integer.class, 0)the typed default for a missing key
getProperty("app.timeout", Duration.class, Duration.ofSeconds(30))a Duration; the same conversions as @Value are available
getProperty("app.url")http://localhost:8091 — placeholders are resolved
containsProperty("app.host")true when any property source has the key
getRequiredProperty("app.api-key")throws IllegalStateException: Required key 'app.api-key' not found

Three edges worth knowing:

  • A key that exists but cannot be converted throws. getProperty("app.bad-port", Integer.class) with app.bad-port=eighty gives ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.lang.Integer] for value [eighty].
  • The Environment resolves ${...} but never evaluates #{...}: getProperty("app.computed") returns #{2*21}.
  • resolvePlaceholders("${app.host}:${app.port} ${app.api-key}") returns localhost:8091 ${app.api-key}, leaving the unknown placeholder alone; resolveRequiredPlaceholders throws PlaceholderResolutionException for it instead.

The limits of @Value

@Value is the right tool for one or two values. AppSettings above shows where it stops scaling:

  • No validation. app.port=-5 injects -5 without complaint, and app.url happily becomes http://localhost:-5. Every range check is code you write yourself.
  • No grouping. Seven related settings mean seven annotations and a seven-parameter constructor, and every other class that needs them repeats all seven.
  • Keys are strings scattered across classes. Rename app.timeout in the file and the compiler has nothing to say. A typo is worse when there is a default: ${app.timout:30s} starts cleanly and uses 30s forever.
  • No IDE metadata. Nothing describes the app.* keys, so an IDE cannot complete them in application.properties or flag one that no code reads.
  • No structured values. A YAML list under app.servers cannot be injected as a whole, as shown above.

@ConfigurationProperties solves all five, and it is the subject of the next article.

.properties vs .yml: which one should you use?

application.propertiesapplication.yml
Structureone full dotted key per linenesting by indentation, spaces only
Separator=, : or whitespacea colon followed by a space
Comments# or ! at the start of a line; a # mid-line is part of the value# after whitespace, anywhere on a line
Listsa,b,c in one key, or key[0]= and key[1]=- item blocks or [a, b]
Multi-line strings\n escapes and \ line continuation|, > and |-
Typesevery value is a String until convertedYAML 1.1 typing: ontrue, 012383, 12:30750 unless quoted
Non-ASCII textdecoded as ISO-8859-1, so Xin chàoXin chà o unless escapedread as UTF-8
Duplicate keysthe last one wins silentlystartup fails with found duplicate key
Typosa wrong line is usually still a valid linea tab or a stray : stops startup
Searchgrep app.owner.email finds the keythe key is split across lines, so the same grep finds nothing
Same key in both fileswinsloses

Pick .properties for small, flat configuration, for files that scripts edit, and whenever every key should be greppable. Pick .yml for deep hierarchies and lists, and for any configuration that carries Vietnamese text. Either way, pick one per project: the two files work together, but a key defined in both with a silent winner is hard to trace.

FAQ

Does Spring Boot read application.properties as UTF-8?

No. In Spring Boot 4.1.1 a .properties configuration file is decoded as ISO-8859-1 unless an encoding is specified, so UTF-8 Vietnamese text such as Xin chào is read as Xin chà o. Use \u00e0-style escapes, move the values to application.yml, which is read as UTF-8, or import a separate file with [encoding=utf-8].

Can I use application.properties and application.yml together?

Yes. Both are loaded and merged key by key. When the same key is in both files in the same location, the value from application.properties is used.

Why is my @Value field null?

Two causes account for almost every case. The field is static: Spring skips it and logs one INFO line.

Text
2026-09-11T14:47:25.774+07:00  INFO 48742 --- [demo] [           main] f.a.AutowiredAnnotationBeanPostProcessor : Autowired annotation is not supported on static fields: private static java.lang.String com.example.demo.StaticProbe.name

Or the object was created with new instead of being taken from the container: new FieldStyle().name() returns null, because nothing ever processed its annotations. A missing key is not a cause — that fails at startup instead.

How do I inject a list from application.yml with @Value?

A YAML list is stored as indexed keys, so @Value("${app.servers}") fails with Could not resolve placeholder 'app.servers', while @Value("${app.servers[0]}") works. To inject a whole list with @Value, write it as one comma-separated string — names: alice,bob,carol — and inject a List<String>, which gives [alice, bob, carol]. Binding a real YAML list to a List is a job for @ConfigurationProperties.

Why did 0123 turn into 83 in application.yml?

SnakeYAML 2.6 follows YAML 1.1, where an unquoted number with a leading zero is octal, so 0123 is 83. Boot stores the Integer, and reading it as a string gives 83. Write "0123". The same rules turn on into true, 1.10 into 1.1 and 12:30 into 750.

What is the difference between ${} and #{} in @Value?

${key} looks a key up in the Environment and substitutes its value as text. #{expression} evaluates a SpEL expression. They combine, as in #{'${app.names}'.split(',')}, and placeholders are always resolved before the expression is evaluated.

How do I write a literal ${ in a property value?

Escape the dollar sign with a backslash. In .properties the file parser removes one backslash first, so write \\${literal}; in YAML a plain or single-quoted \${literal} is enough. Both read back as ${literal}.

Conclusion

Both files end up in the same place: a flat map of dotted String keys in the Environment, with application.properties winning when a key appears in both. Where they differ is what they do to your text on the way in. .properties decodes as ISO-8859-1, keeps trailing spaces and treats a backslash as an escape; YAML reads UTF-8 but applies YAML 1.1 typing, so NO, 0123, 1.10 and 12:30 need quotes. Placeholders are resolved on every read, ${random.*} included. @Value reads a key, resolves placeholders, evaluates SpEL, converts the result, and fails the startup when a key has neither a value nor a default.

That is enough to read a handful of values. It is not enough for a group of related settings that must be validated, and that is where the next article picks up: @ConfigurationProperties — type-safe configuration combined with validation.

Related Posts

[Spring Boot Basics] IoC and Dependency Injection in Spring: Why You Stop Calling new

The idea the whole framework rests on, demonstrated on Spring Boot 4.1.1 and Java 21: a four-class object graph built with new at every level and the three failures that follow, Inversion of Control and Dependency Injection named separately, the same graph hand-wired in main with no framework at all, then wired by the Spring container with the injected instance identities printed to prove it, plus a JUnit 5 test with a hand-made stub, an implementation swapped without touching its consumer, and an honest list of what the container costs you.

[Spring Boot Basics] API Documentation in Spring Boot with springdoc-openapi and Swagger UI

springdoc-openapi 3.1.1 on Spring Boot 4.1.1, checked on a running jar: the OpenAPI 3.1 document at /v3/api-docs, Swagger UI and Try it out, what springdoc infers from controllers, DTO records and Bean Validation constraints, which @RestControllerAdvice responses it adds, @Tag, @Operation, @ApiResponse, @Parameter and @Schema on records, a global OpenAPI bean and customizer, GroupedOpenApi, springdoc properties and switching the docs off in a prod profile.

[Spring Boot Basics] @ConfigurationProperties in Spring Boot: Type-Safe Configuration with Validation

Type-safe configuration in Spring Boot 4.1.1 with @ConfigurationProperties, checked against real runs: binding to records without @ConstructorBinding, JavaBean binding and @DefaultValue, the three ways to register a properties class, nested objects, lists, maps, enums, Duration and DataSize conversion, relaxed binding and environment variable names, @Validated fail-fast startup errors, the configuration processor metadata, and a side-by-side comparison with @Value.

[Spring Boot Basics] Server-Side Rendering with Thymeleaf in Spring Boot: Templates, Forms and Validation

Server-side rendering with Thymeleaf 3.1.5 in Spring Boot 4.1.1, checked on a running application: when SSR beats a JSON API, how a view name becomes classpath:/templates/products/list.html, the five standard expressions, th:text versus th:utext and XSS, th:each with #numbers and #temporals, a validated create form with th:field and th:errors, where BindingResult must go, Post/Redirect/Get with flash attributes, fragments, static CSS, what spring.thymeleaf.cache really changes, and HTML error pages.