Properties and Configuration Files
Load key=value configuration with java.util.Properties, layer defaults and overrides, and turn strings into a validated, typed config at startup.
Properties props = new Properties(defaults);
props.load(reader);
String value = props.getProperty("key", "fallback");
Set<String> keys = props.stringPropertyNames();java.util.Properties reads and writes the .properties format: one key=value per line, # or ! for comments. It is the format of Spring Boot's application.properties, of many JDBC drivers and of countless older services, so it is worth knowing its rules exactly.
Loading
Properties props = new Properties();
try (Reader in = Files.newBufferedReader(Path.of("app.properties"))) {
props.load(in);
}
String url = props.getProperty("db.url"); // null if missing
int port = Integer.parseInt(props.getProperty("server.port", "8080"));
Load from a Reader. load(InputStream) always decodes ISO-8859-1, so any non-Latin-1 text written in UTF-8 comes back garbled. Files.newBufferedReader uses UTF-8.
To load a file packaged in your JAR, use getClass().getResourceAsStream("/app.properties") wrapped in an InputStreamReader with UTF-8. It returns null when the resource is missing, so check before loading.
The format's surprises
- The separator can be
=,:or plain whitespace, and spaces around it are ignored. - A backslash is an escape character.
C:\tempbecomesC:followed by a tab andemp. WriteC:\\tempor use forward slashes. - A line ending in a backslash continues on the next line, with that line's leading whitespace removed.
- Every value is a
String."true "with a trailing space is nottruetoBoolean.parseBoolean, so strip values before parsing.
Typed configuration and failing fast
Do not scatter getProperty calls through the code. Read everything once at startup into a record, parse numbers, durations and booleans there, and validate ranges. A missing database URL should stop the service at boot with a clear message, not cause a NullPointerException on the first request an hour later. In Kubernetes, a pod that fails at startup never receives traffic and the bad rollout is caught straight away.
Layering
Real services combine sources: defaults packaged with the code, a file per environment, then environment variables and system properties that win over both. new Properties(defaults) gives a fallback chain for getProperty, and environment variables are conventionally the dotted key in upper case with underscores, such as DB_POOL_SIZE for db.pool.size. Spring Boot applies the same idea with more sources and relaxed name matching.
Secrets
Keep passwords and API keys out of properties files in the repository. Read them from environment variables or a secret store at runtime, and never log the full config object if it holds them.
Properties extends Hashtable<Object, Object>, so iteration order is not the file order. Use stringPropertyNames() and sort when you need a stable listing.
Example
When to use it
- Load database, port and timeout settings once at startup into a validated record and refuse to start when one is missing.
- Ship sensible defaults inside the JAR and let each environment override them with environment variables in its deployment manifest.
- Read a supplier's properties-based configuration file that contains Windows paths and non-English text without corrupting either.
More examples
Defaults, file values and overrides
The defaults passed to the constructor are not copied into the new Properties; they are consulted only when a key is missing, which is why keySet sees one entry and stringPropertyNames sees three. Taking the environment as a parameter also makes the lookup easy to test.
static String lookup(String key, Map<String, String> env, Properties file) {
String fromSystem = System.getProperty(key);
if (fromSystem != null) {
return fromSystem + " (system property)";
}
String envName = key.toUpperCase(Locale.ROOT).replace('.', '_');
String fromEnv = env.get(envName);
if (fromEnv != null) {
return fromEnv + " (environment " + envName + ")";
}
return file.getProperty(key) + " (properties)";
}
void main() {
Properties defaults = new Properties();
defaults.setProperty("server.port", "8080");
defaults.setProperty("log.level", "INFO");
defaults.setProperty("db.pool.size", "10");
Properties file = new Properties(defaults); // falls back to defaults
file.setProperty("log.level", "DEBUG");
// In a real service: System.getenv(). A fixed map keeps the demo repeatable.
Map<String, String> env = Map.of("DB_POOL_SIZE", "20");
System.setProperty("server.port", "9090"); // as if started with -Dserver.port=9090
for (String key : List.of("server.port", "log.level", "db.pool.size")) {
IO.println(key + " = " + lookup(key, env, file));
}
IO.println("keys in file alone: " + file.keySet().size()
+ ", visible through getProperty: " + file.stringPropertyNames().size());
}
// Output:
// server.port = 9090 (system property)
// log.level = DEBUG (properties)
// db.pool.size = 20 (environment DB_POOL_SIZE)
// keys in file alone: 1, visible through getProperty: 3Why load(Reader) matters for UTF-8 files
The accented letter is two bytes in UTF-8, and the stream version turns each byte into its own character. The bug only shows up with non-English text, which is why it often reaches production through a shop name or an email template.
void main() throws IOException {
byte[] bytes = "shop.name=caf\u00e9\n".getBytes(StandardCharsets.UTF_8);
Properties fromStream = new Properties();
fromStream.load(new ByteArrayInputStream(bytes)); // decodes as ISO-8859-1
Properties fromReader = new Properties();
fromReader.load(new InputStreamReader(new ByteArrayInputStream(bytes), StandardCharsets.UTF_8));
String expected = "caf\u00e9";
String a = fromStream.getProperty("shop.name");
String b = fromReader.getProperty("shop.name");
IO.println("InputStream: length " + a.length() + ", correct " + a.equals(expected));
IO.println("Reader: length " + b.length() + ", correct " + b.equals(expected));
}
// Output:
// InputStream: length 5, correct false
// Reader: length 4, correct trueBackslashes, continuations and separators
The properties text holds single backslashes in upload.dir, which the loader treats as escapes and drops; doubling them keeps the Windows path. The continuation line loses its leading spaces, so a long comma-separated list can be wrapped without adding spaces to the value.
void main() throws IOException {
String file = """
upload.dir=C:\\apps\\shop
upload.dir.fixed=C:\\\\apps\\\\shop
allowed.origins=https://shop.example,\\
https://admin.example
greeting Hello there
""";
Properties props = new Properties();
props.load(new StringReader(file));
// Properties has no defined order; sort the names for stable output.
for (String key : new TreeSet<>(props.stringPropertyNames())) {
IO.println(key + " -> [" + props.getProperty(key) + "]");
}
}
// Output:
// allowed.origins -> [https://shop.example,https://admin.example]
// greeting -> [Hello there]
// upload.dir -> [C:appsshop]
// upload.dir.fixed -> [C:\apps\shop]
Discussion