Java reads environment variables through System.getenv(), which returns an unmodifiable Map<String, String>. Once the JVM starts, that map is read-only — there is no portable API to set or remove a variable from inside the process. That immutability is the central gotcha for developers coming from Node.js or Python: you cannot "patch" the environment at runtime, so configuration must be sourced before the JVM starts or layered through a higher-level abstraction. In modern Spring Boot apps, that higher-level abstraction is the Environment / @Value machinery, which automatically maps SPRING_DATASOURCE_URL to spring.datasource.url via relaxed binding. Plain JVM apps get the primitive APIs, Spring Boot apps get the mapping rules, and everything else gets dotenv-java as the escape hatch.
How do you read an environment variable in Java?
Two methods on java.lang.System matter: getenv(String) for a single variable and getenv() for the whole map. Both return null for unset keys — guard with Optional or Map.getOrDefault to avoid NullPointerException.
// Single value — returns null if unset
String dbUrl = System.getenv("DATABASE_URL");
// With a default
String port = System.getenv().getOrDefault("PORT", "3000");
// Optional-style guard
String apiKey = Optional.ofNullable(System.getenv("API_KEY"))
.orElseThrow(() -> new IllegalStateException("API_KEY not set"));
// Whole map — unmodifiable
Map<String, String> all = System.getenv();
all.forEach((k, v) -> System.out.println(k + "=" + v));Environment variables vs JVM system properties
Java has a second, separate namespace: system properties, accessed via System.getProperty and set with the -D flag. Many libraries (especially older ones) read system properties rather than environment variables — Java logging, the SSL truststore, and almost everything in the java. namespace.
# Environment variable
export DATABASE_URL=postgres://localhost/mydb
java -jar app.jar
# System property — same value reached via System.getProperty("database.url")
java -Ddatabase.url=postgres://localhost/mydb -jar app.jar
# Java built-in system properties worth knowing
java.version # JVM version
java.home # JRE installation dir
user.home # User home directory
file.separator # / or \
line.separator # \n or \r\n
os.name # "Linux", "Mac OS X", "Windows 11"When debugging configuration issues, check both: a missing value might be in the wrong namespace, not actually unset. The classic pair is JAVA_HOME — the environment variable Maven, Gradle, and IDEs use to pick a JDK — versus java.home, the system property the already-running JVM reports about itself. Setting one never changes the other. On Windows, where JAVA_HOME is set persistently through the registry, the Windows environment variables guide covers the setx pitfalls.
How does Spring Boot bind environment variables?
Spring Boot's relaxed binding automatically maps environment variables to property names. Underscores become dots, uppercase becomes lowercase, and the resulting property is available everywhere Spring's configuration system reaches — @Value, @ConfigurationProperties, application.yml placeholders, and the Environment bean.
# Environment variable Spring property
SPRING_DATASOURCE_URL → spring.datasource.url
SPRING_DATASOURCE_USERNAME → spring.datasource.username
SERVER_PORT → server.port
LOGGING_LEVEL_ROOT → logging.level.root
MYAPP_FEATURE_NEW_UI_ENABLED → myapp.feature.new-ui.enabled// Inject via @Value
@Value("${spring.datasource.url}") String dbUrl;
// Or as a typed bean
@ConfigurationProperties(prefix = "myapp")
public record AppConfig(
String name,
int maxConnections,
Duration timeout
) {}
# In application.yml — defaults baked in, env vars override
myapp:
name: my-app
max-connections: 100
timeout: 30sSpring's precedence order (loosely): command-line args → JNDI → JVM system properties → OS environment variables → application-{profile}.yml → application.yml → defaults. Higher sources override lower ones.
How do you load a .env file in Java?
Spring Boot doesn't read .env files natively. The community standard for non-Spring apps is dotenv-java, which exposes its own API rather than mutating System.getenv() (since the latter is read-only):
// Maven: io.github.cdimascio:dotenv-java:3.0.0
import io.github.cdimascio.dotenv.Dotenv;
Dotenv dotenv = Dotenv.configure()
.directory("./config")
.ignoreIfMalformed()
.ignoreIfMissing()
.load();
String dbUrl = dotenv.get("DATABASE_URL");
String port = dotenv.get("PORT", "3000");For Spring Boot, use spring-dotenv as a build-time plugin or a custom PropertySource that reads .env at startup.
Practical patterns and gotchas
- The environment is immutable from inside the JVM. There is no standard
System.setenv. If you genuinely need runtime mutation for a test, hack it with reflection (Linux/Mac) or JNI — but most of the time you actually want a config object you control. - JAVA_OPTS and JAVA_TOOL_OPTIONS let containers pass JVM flags via the environment without changing the entrypoint.
JAVA_TOOL_OPTIONSis honoured by every JVM tool, includingjstackandjcmd. - Spring Boot relaxed binding handles kebab-case:
MYAPP_FEATURE_NEW_UIbinds tomyapp.feature.new-ui. Use@ConfigurationPropertieswith kebab-case property names; Spring translates automatically. - Container env-only deployment: in modern container orchestrators, JVM apps usually skip config files entirely and rely on environment variables injected by Kubernetes ConfigMaps / Secrets, AWS Parameter Store, or Azure Key Vault. See the env-vars tips guide for cross-platform patterns.
- Validate your .env file before committing with the .env validator.
When should you not read secrets straight from System.getenv()?
Environment variables are fine for non-sensitive configuration, but they are not a vault. On Linux any process running as the same user can read another process's environment from /proc/<pid>/environ, and a JVM heap dump or a stray System.getenv() log line spills every value at once. For real secrets — database passwords, signing keys, API tokens — fetch from a dedicated store at startup (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Azure Key Vault) and keep only short-lived values in memory. The environment variable security guide walks through the attack surface in detail.
The other limit is the bootstrap window. Because the environment is resolved before Spring's ApplicationContext exists, anything you need before the context starts — the logging config, the active profile, the config-server URL itself — has to come from a real environment variable or a -D system property. You cannot bind it through @Value or @ConfigurationProperties yet, because the machinery that does the binding has not been wired up at that point.
For Java's static final and enum constants, see constants in Java.