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

# How to Read Environment Variables and Properties in Java 21
- URL: https://www.ggorantala.dev/how-to-read-environment-variables-system-properties-java/
- Published: 2026-05-11T05:02:00.000Z
- Updated: 2026-10-31T06:34:20.000Z
- Description: How to read environment variables and system properties in Java 21: one Settings class, a clear precedence rule, typed values, masked secrets and 16 checks.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-io, java-tutorials

## The config you'll end up with

A parcel-tracking command-line tool needs an API address, a retry count, a timeout and an API key. On a laptop you want defaults. In a container you want environment variables. When you're debugging you want to override one value without touching anything else. This is what the finished program prints in three situations: nothing set, then a mix of `-D` flags and environment variables, then a bad number.

```log
$ java -cp out dev.ggorantala.parcel.Main
parcel.api.url = (using default)  [default]
parcel.retries = (using default)  [default]
parcel.timeout = (using default)  [default]
parcel.verbose = (using default)  [default]
parcel.api.key = (using default)  [default]
-> would call http://localhost:8080 up to 4 times, 10s each, verbose=false
api key present: false
$ PARCEL_API_URL=http://env.example PARCEL_RETRIES=5 PARCEL_API_KEY=abc123 \
  java -Dparcel.api.url=http://prop.example -Dparcel.timeout=30 -Dparcel.verbose=true -cp out dev.ggorantala.parcel.Main
parcel.api.url = http://prop.example  [system property]
parcel.retries = 5  [environment variable PARCEL_RETRIES]
parcel.timeout = 30  [system property]
parcel.verbose = true  [system property]
parcel.api.key = ****  [environment variable PARCEL_API_KEY]
-> would call http://prop.example up to 6 times, 30s each, verbose=true
api key present: true
$ PARCEL_RETRIES=three java -cp out dev.ggorantala.parcel.Main
Exception in thread "main" java.lang.IllegalStateException: Setting 'parcel.retries' must be a whole number but was 'three'
	at dev.ggorantala.parcel.Settings.parse(Settings.java:68)

```

If you searched for **how to read environment variables in Java**, the one-liner is `System.getenv("NAME")`, and for a `-D` flag it's `System.getProperty("name")`. Both return `null` when the value isn't there, and that `null` is where most config bugs start. So this article builds a small `Settings` class on top of them with one rule (property first, then environment variable, then default), typed getters that fail loudly, and a startup line that tells you where each value came from without printing the secret.

It's for you if you can compile and run a Java class from a terminal. There are three classes, a demo and a checks file. No framework, no build tool, no container.

I built it on plain `javac` because Maven Central wasn't reachable from my build machine. Writing `Settings` was the quick part. Most of the time went on the traps near the end, because several of them print something that looks right and isn't.

## What you need to follow along

| Tool              | Version I used                   | Check it with  |
| ----------------- | -------------------------------- | -------------- |
| JDK               | OpenJDK 21.0.12.1 (Ubuntu build) | java -version  |
| javac             | 21.0.12.1                        | javac -version |
| OS                | Ubuntu 24.04 (Linux)             | uname -a       |
| Shell             | bash                             | echo $SHELL    |
| Build tool        | none                             |                |
| Container runtime | none needed                      |                |

The `Settings` class compiles with `--release 17` too (I checked), so Java 17 works. If you need a JDK, SDKMAN installs one with `sdk install java 21.0.12-tem`; check `sdk list java` for the exact identifier on your machine, I didn't run SDKMAN myself.

The shell examples use `NAME=value command`, which works in bash and zsh. In PowerShell it's `$env:NAME="value"; java ...`, and in cmd it's `set NAME=value` on its own line. I didn't run either on Windows.

## Create the parcel-tracker project

```bash
mkdir -p parcel/src/main/java/dev/ggorantala/parcel
mkdir -p parcel/src/test/java/dev/ggorantala/parcel
cd parcel

```

```log
parcel/
├── src/main/java/dev/ggorantala/parcel/
│   ├── Basics.java
│   ├── Settings.java
│   ├── Main.java
│   └── Traps.java
└── src/test/java/dev/ggorantala/parcel/
    └── Checks.java

```

**In IntelliJ IDEA:** File | New | Project, choose Java, pick JDK 21, then create the package `dev.ggorantala.parcel`. To set an environment variable or a `-D` flag for a run, open Run | Edit Configurations, pick your Main class, and use the Environment variables field and the VM options field (`-Dparcel.timeout=30`). If you don't see VM options, click Modify options and tick it. I did not run IntelliJ for this article; the names come from the JetBrains help pages.

**In Eclipse:** File | New | Java Project with JavaSE-21, then File | New | Package. For a launch: Run | Run Configurations, select your Java Application, then the Arguments tab for VM arguments and the Environment tab for variables. Same caveat: docs only, not executed.

**From the command line** (what I ran):

```bash
mkdir -p out
javac --release 21 -Xlint:all -d out $(find src -name '*.java')

```

On Windows PowerShell, `$(find ...)` doesn't exist. Use `javac --release 21 -d out (Get-ChildItem -Recurse -Filter *.java src | ForEach-Object FullName)`. Not executed by me.

## How to read environment variables and system properties in Java, step by step

### Step 1: Call the raw JDK methods and look at what comes back

Goal: see the five calls you'll build on, with their `null` behaviour on screen.

`src/main/java/dev/ggorantala/parcel/Basics.java`

```java
package dev.ggorantala.parcel;

import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;

/** The raw JDK calls, one per line, so you can see what each returns. */
public class Basics {
    public static void main(String[] args) {
        // Environment variables
        System.out.println("HOME            = " + System.getenv("HOME"));
        System.out.println("NO_SUCH_VAR     = " + System.getenv("NO_SUCH_VAR"));
        Map<String, String> env = System.getenv();
        System.out.println("env is a " + env.getClass().getName());
        try {
            env.put("X", "1");
        } catch (UnsupportedOperationException e) {
            System.out.println("env.put -> UnsupportedOperationException (read-only)");
        }

        // System properties
        System.out.println("java.version    = " + System.getProperty("java.version"));
        System.out.println("os.name         = " + System.getProperty("os.name"));
        System.out.println("user.dir        = " + System.getProperty("user.dir"));
        System.out.println("no.such.prop    = " + System.getProperty("no.such.prop"));
        System.out.println("with default    = " + System.getProperty("no.such.prop", "fallback"));
        System.out.println("file.separator  = '" + System.getProperty("file.separator") + "'");
        System.out.println("line.separator escaped = "
                + System.lineSeparator().replace("\n", "\\n").replace("\r", "\\r"));

        // Typed readers on System
        System.out.println("Integer.getInteger  = " + Integer.getInteger("parcel.retries", 3));
        System.out.println("Boolean.getBoolean  = " + Boolean.getBoolean("parcel.verbose"));
        System.out.println("Long.getLong        = " + Long.getLong("parcel.big", 5L));

        // Setting a property at runtime
        System.setProperty("parcel.runtime", "yes");
        System.out.println("after setProperty   = " + System.getProperty("parcel.runtime"));
        System.clearProperty("parcel.runtime");
        System.out.println("after clearProperty = " + System.getProperty("parcel.runtime"));

        // Listing: only parcel.* properties
        Properties all = System.getProperties();
        Map<String, String> mine = new TreeMap<>();
        for (String name : all.stringPropertyNames()) {
            if (name.startsWith("parcel.")) mine.put(name, all.getProperty(name));
        }
        System.out.println("parcel.* properties = " + mine);
    }
}

```

Run it with three `-D` flags so there's something to read:

```bash
java -Dparcel.retries=7 -Dparcel.verbose=true -Dparcel.zone=eu -cp out dev.ggorantala.parcel.Basics

```

```log
HOME            = /root
NO_SUCH_VAR     = null
env is a java.util.Collections$UnmodifiableMap
env.put -> UnsupportedOperationException (read-only)
java.version    = 21.0.12.1
os.name         = Linux
user.dir        = /tmp/w/parcel
no.such.prop    = null
with default    = fallback
file.separator  = '/'
line.separator escaped = \n
Integer.getInteger  = 7
Boolean.getBoolean  = true
Long.getLong        = 5
after setProperty   = yes
after clearProperty = null
parcel.* properties = {parcel.retries=7, parcel.verbose=true, parcel.zone=eu}

```

Checkpoint: `NO_SUCH_VAR = null`, the environment map is read-only, and the last line lists exactly your three flags. If `HOME` prints `null` you're on Windows, where it's `USERPROFILE`.

Three things to notice. `System.getenv()` hands you an unmodifiable map, so a Java program can read its environment but never change it. `System.getProperty` has a two-argument form that supplies a default, `System.getenv` does not. And `Integer.getInteger("parcel.retries", 3)` read the *property* named `parcel.retries`; it did not parse the string "parcel.retries". The name is confusing and trap 1 below shows why it bites.

### Step 2: Write one Settings class with a single precedence rule

Goal: one place that answers "what is this setting?" the same way everywhere.

My rule is: a `-D` property wins, then an environment variable, then the default. The `-D` flag is typed by a person on purpose for this run. The environment variable is whatever the container or shell happened to carry. I'd rather the deliberate thing win.

Environment variable names are conventionally upper case with underscores, property names are lower case with dots, so `Settings` converts: `parcel.api.url` becomes `PARCEL_API_URL`.

The constructor takes two lookup functions instead of calling `System.getenv` directly. That's the whole testing story: in a check I pass maps, in production I pass `System::getenv`.

`src/main/java/dev/ggorantala/parcel/Settings.java`

```java
package dev.ggorantala.parcel;

import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;

/**
 * Reads settings with one precedence rule: -D system property, then environment
 * variable, then the default. Both lookups are injected so tests don't touch
 * the real process environment.
 */
public final class Settings {

    private final Function<String, String> properties;
    private final Function<String, String> environment;

    public Settings(Function<String, String> properties, Function<String, String> environment) {
        this.properties = Objects.requireNonNull(properties);
        this.environment = Objects.requireNonNull(environment);
    }

    /** The real thing: System.getProperty and System.getenv. */
    public static Settings fromJvm() {
        return new Settings(System::getProperty, System::getenv);
    }

    /** Test helper: fixed maps instead of the real JVM state. */
    public static Settings of(Map<String, String> props, Map<String, String> env) {
        return new Settings(props::get, env::get);
    }

    /** parcel.api.url becomes PARCEL_API_URL. */
    static String envName(String key) {
        return key.toUpperCase().replace('.', '_').replace('-', '_');
    }

    public Optional<String> find(String key) {
        String fromProp = properties.apply(key);
        if (fromProp != null && !fromProp.isBlank()) {
            return Optional.of(fromProp.strip());
        }
        String fromEnv = environment.apply(envName(key));
        if (fromEnv != null && !fromEnv.isBlank()) {
            return Optional.of(fromEnv.strip());
        }
        return Optional.empty();
    }

    public String string(String key, String fallback) {
        return find(key).orElse(fallback);
    }

    public String required(String key) {
        return find(key).orElseThrow(() -> new IllegalStateException(
                "Missing setting '" + key + "': pass -D" + key + "=... or set " + envName(key)));
    }

    public int integer(String key, int fallback) {
        return find(key).map(v -> parse(key, v)).orElse(fallback);
    }

    private static int parse(String key, String value) {
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            throw new IllegalStateException("Setting '" + key + "' must be a whole number but was '" + value + "'", e);
        }
    }

    public boolean bool(String key, boolean fallback) {
        return find(key).map(v -> {
            if (v.equalsIgnoreCase("true")) return true;
            if (v.equalsIgnoreCase("false")) return false;
            throw new IllegalStateException("Setting '" + key + "' must be true or false but was '" + v + "'");
        }).orElse(fallback);
    }

    public Duration seconds(String key, Duration fallback) {
        return find(key).map(v -> Duration.ofSeconds(parse(key, v))).orElse(fallback);
    }

    /** Which source won, for the startup log. Never prints the value of a secret. */
    public String describe(String key, boolean secret) {
        String fromProp = properties.apply(key);
        String fromEnv = environment.apply(envName(key));
        String source = (fromProp != null && !fromProp.isBlank()) ? "system property"
                : (fromEnv != null && !fromEnv.isBlank()) ? "environment variable " + envName(key)
                : "default";
        String shown = secret ? (find(key).isPresent() ? "****" : "(using default)") : find(key).orElse("(using default)");
        return key + " = " + shown + "  [" + source + "]";
    }
}

```

Why these choices: `find` treats a blank value as missing, because `PARCEL_API_URL=` (set but empty) is almost always a deployment mistake and a blank URL is never what you want. It also strips whitespace, because I've seen a trailing newline sneak in from a secret file. The typed getters throw `IllegalStateException` with the setting name and the bad value, instead of quietly falling back to the default. `required` says *how to fix it* in its message.

Checkpoint: compile.

```bash
javac --release 21 -Xlint:all -d out $(find src -name '*.java')

```

No output means success.

### Step 3: Print where every value came from, and hide the secret

Goal: a startup log that makes "why is it calling the wrong URL?" a ten-second question.

`src/main/java/dev/ggorantala/parcel/Main.java`

```java
package dev.ggorantala.parcel;

import java.time.Duration;

public class Main {
    public static void main(String[] args) {
        Settings s = Settings.fromJvm();

        String url = s.string("parcel.api.url", "http://localhost:8080");
        int retries = s.integer("parcel.retries", 3);
        Duration timeout = s.seconds("parcel.timeout", Duration.ofSeconds(10));
        boolean verbose = s.bool("parcel.verbose", false);

        System.out.println(s.describe("parcel.api.url", false));
        System.out.println(s.describe("parcel.retries", false));
        System.out.println(s.describe("parcel.timeout", false));
        System.out.println(s.describe("parcel.verbose", false));
        System.out.println(s.describe("parcel.api.key", true));
        System.out.println("-> would call " + url + " up to " + (retries + 1) + " times, "
                + timeout.toSeconds() + "s each, verbose=" + verbose);
        System.out.println("api key present: " + s.find("parcel.api.key").isPresent());

        if (args.length > 0 && args[0].equals("--strict")) {
            s.required("parcel.api.key");   // throws with a message that says how to fix it
            System.out.println("strict mode: key found");
        }
    }
}

```

`describe` reports the winning source and masks anything you flag as secret. Never log the value of an API key, even at debug level; the log file lives longer than the key rotation.

Checkpoint: run it with nothing set, and you should see `(using default)` five times:

```bash
java -cp out dev.ggorantala.parcel.Main

```

```log
parcel.api.url = (using default)  [default]
parcel.retries = (using default)  [default]
parcel.timeout = (using default)  [default]
parcel.verbose = (using default)  [default]
parcel.api.key = (using default)  [default]
-> would call http://localhost:8080 up to 4 times, 10s each, verbose=false
api key present: false

```

### Step 4: Fail fast when a required setting is missing

Goal: crash at startup with a message that tells the operator what to type.

The `--strict` flag in `Main` calls `s.required("parcel.api.key")`. Run it without the key:

```bash
java -cp out dev.ggorantala.parcel.Main --strict

```

```log
Exception in thread "main" java.lang.IllegalStateException: Missing setting 'parcel.api.key': pass -Dparcel.api.key=... or set PARCEL_API_KEY

```

\[... stack trace cut\]

That message names both ways to supply the value. Whoever is paged at 2 a.m. doesn't have to open the source.

## Run it with and without overrides

You already ran the defaults. Now mix the sources. The environment sets the URL, retries and key; the command line overrides the URL and sets the timeout and verbose flag:

```bash
PARCEL_API_URL=http://env.example PARCEL_RETRIES=5 PARCEL_API_KEY=abc123 \
java -Dparcel.api.url=http://prop.example -Dparcel.timeout=30 -Dparcel.verbose=true \
  -cp out dev.ggorantala.parcel.Main

```

```log
parcel.api.url = http://prop.example  [system property]
parcel.retries = 5  [environment variable PARCEL_RETRIES]
parcel.timeout = 30  [system property]
parcel.verbose = true  [system property]
parcel.api.key = ****  [environment variable PARCEL_API_KEY]
-> would call http://prop.example up to 6 times, 30s each, verbose=true
api key present: true

```

Read the brackets. The URL came from the property even though the environment variable was also set. The retry count came from the environment (`5`, so six attempts). The key shows as `****` and its source is `PARCEL_API_KEY`, and the value `abc123` appears nowhere in the output.

Now a bad value:

```bash
PARCEL_RETRIES=three java -cp out dev.ggorantala.parcel.Main

```

```log
Exception in thread "main" java.lang.IllegalStateException: Setting 'parcel.retries' must be a whole number but was 'three'
	at dev.ggorantala.parcel.Settings.parse(Settings.java:68)

```

\[... stack trace cut\]

Compare that with what `Integer.getInteger("parcel.retries", 3)` would have done: silently used `3`. See trap 2.

### Passing settings to a child process

If your program starts another program (see [how to use ProcessBuilder to run commands in Java](https://ggorantala.dev/how-to-use-processbuilder-run-commands-java/?ref=ggorantala.dev)), the child inherits a copy of your environment, and `pb.environment()` is the copy you edit. Trap 5 below shows it.

## Check the rules with code

`src/test/java/dev/ggorantala/parcel/Checks.java`

```java
package dev.ggorantala.parcel;

import java.time.Duration;
import java.util.Map;

public class Checks {
    static int passed = 0, failed = 0;

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS  " + name); }
        else { failed++; System.out.println("FAIL  " + name); }
    }

    static void throwsIllegalState(String name, Runnable r, String mustContain) {
        try { r.run(); check(name, false); }
        catch (IllegalStateException e) { check(name, e.getMessage().contains(mustContain)); }
    }

    public static void main(String[] args) {
        Map<String, String> none = Map.of();

        check("envName maps dots and dashes", Settings.envName("parcel.api-key.v2").equals("PARCEL_API_KEY_V2"));
        check("default when nothing is set",
                Settings.of(none, none).string("parcel.api.url", "d").equals("d"));
        check("env var used when no property",
                Settings.of(none, Map.of("PARCEL_API_URL", "env")).string("parcel.api.url", "d").equals("env"));
        check("property beats env var",
                Settings.of(Map.of("parcel.api.url", "prop"), Map.of("PARCEL_API_URL", "env"))
                        .string("parcel.api.url", "d").equals("prop"));
        check("blank property falls through to env",
                Settings.of(Map.of("parcel.api.url", "  "), Map.of("PARCEL_API_URL", "env"))
                        .string("parcel.api.url", "d").equals("env"));
        check("value is stripped",
                Settings.of(Map.of("parcel.api.url", " x \n"), none).string("parcel.api.url", "d").equals("x"));
        check("integer parses", Settings.of(Map.of("parcel.retries", "5"), none).integer("parcel.retries", 3) == 5);
        check("integer default", Settings.of(none, none).integer("parcel.retries", 3) == 3);
        throwsIllegalState("bad integer fails loudly",
                () -> Settings.of(Map.of("parcel.retries", "three"), none).integer("parcel.retries", 3),
                "must be a whole number but was 'three'");
        check("bool is case-insensitive", Settings.of(Map.of("parcel.verbose", "TRUE"), none).bool("parcel.verbose", false));
        throwsIllegalState("bool rejects yes",
                () -> Settings.of(Map.of("parcel.verbose", "yes"), none).bool("parcel.verbose", false),
                "must be true or false");
        check("seconds -> Duration",
                Settings.of(none, Map.of("PARCEL_TIMEOUT", "30")).seconds("parcel.timeout", Duration.ZERO).equals(Duration.ofSeconds(30)));
        throwsIllegalState("required names both ways to set it",
                () -> Settings.of(none, none).required("parcel.api.key"),
                "-Dparcel.api.key=... or set PARCEL_API_KEY");
        String d = Settings.of(none, Map.of("PARCEL_API_KEY", "s3cret")).describe("parcel.api.key", true);
        check("describe masks secrets", d.contains("****") && !d.contains("s3cret"));
        check("describe names the source", d.contains("environment variable PARCEL_API_KEY"));
        check("describe plain value shows it",
                Settings.of(Map.of("parcel.retries", "7"), none).describe("parcel.retries", false).equals("parcel.retries = 7  [system property]"));

        System.out.println();
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}

```

It's plain Java with no JUnit, because I couldn't download JUnit here. If you use JUnit 5, each `check` becomes one `@Test` with an `assertThat`; the maps-in, string-out design needs no change. A JUnit version of this is coming in [how to write unit tests with JUnit 5](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev).

```bash
javac --release 21 -Xlint:all -d out $(find src -name '*.java')
java -cp out dev.ggorantala.parcel.Checks

```

```log
PASS  envName maps dots and dashes
PASS  default when nothing is set
PASS  env var used when no property
PASS  property beats env var
PASS  blank property falls through to env
PASS  value is stripped
PASS  integer parses
PASS  integer default
PASS  bad integer fails loudly
PASS  bool is case-insensitive
PASS  bool rejects yes
PASS  seconds -> Duration
PASS  required names both ways to set it
PASS  describe masks secrets
PASS  describe names the source
PASS  describe plain value shows it

16 passed, 0 failed

```

To prove the checks can fail, I changed `Settings.find` so the property branch never runs (`if (false)`) and recompiled. Seven of the sixteen checks failed, including "property beats env var", then I put it back. A check you've never seen fail isn't telling you much.

## Why a property beats an environment variable

`System.getenv()` is a snapshot of the environment the operating system gave the JVM process at launch. The JVM copies it once into an unmodifiable map. There is no portable Java way to change it afterwards, which is why trap 4 can't work. System properties are different: they're a live `Properties` table inside the JVM, filled at startup from built-ins like `java.version`, from `-D` flags, and you can change it with `System.setProperty` while the program runs.

That difference is why the order in `Settings` makes sense. A `-D` flag is an explicit argument to *this* launch. An environment variable is ambient, inherited from the shell, the container spec or the CI job. When both exist, the explicit one is the one somebody typed on purpose.

Environment variable names are case-sensitive on Linux and macOS and case-insensitive on Windows. My check run on Linux confirms the first half: `parcel_api_url=http://low` was ignored and the default was used. I didn't run Windows. That's why the converter in `Settings` always produces the upper-case form, and why you should never rely on two variables differing only by case.

For the deeper picture of what happens when a process is created, the ProcessBuilder article linked above covers inheritance of the environment.

## Mistakes I made on purpose

I forced each of these. Run `Traps.java` to see them. It's the one file in the project that's only there to show you what goes wrong:

`src/main/java/dev/ggorantala/parcel/Traps.java`

```java
package dev.ggorantala.parcel;

import java.io.IOException;
import java.util.List;

public class Traps {
    public static void main(String[] args) throws Exception {
        System.out.println("-- trap 1: Boolean.getBoolean reads a PROPERTY NAMED 'true'?");
        System.setProperty("parcel.verbose", "TRUE");
        System.out.println("Boolean.getBoolean(\"parcel.verbose\") with TRUE = " + Boolean.getBoolean("parcel.verbose"));
        System.setProperty("parcel.verbose", "yes");
        System.out.println("Boolean.getBoolean(\"parcel.verbose\") with yes  = " + Boolean.getBoolean("parcel.verbose"));
        System.out.println("Boolean.getBoolean(\"true\")                      = " + Boolean.getBoolean("true"));

        System.out.println("-- trap 2: Integer.getInteger swallows a bad number");
        System.setProperty("parcel.retries", "three");
        System.out.println("Integer.getInteger = " + Integer.getInteger("parcel.retries", 3));

        System.out.println("-- trap 3: empty -D value is an empty string, not null");
        System.out.println("[" + System.getProperty("parcel.empty") + "]");

        System.out.println("-- trap 4: setProperty does not change the environment, and env can't be changed");
        System.setProperty("PARCEL_API_URL", "http://prop.example");
        System.out.println("getenv(PARCEL_API_URL) = " + System.getenv("PARCEL_API_URL"));

        System.out.println("-- trap 5: a child process gets a COPY of the environment");
        ProcessBuilder pb = new ProcessBuilder(List.of("sh", "-c", "echo child sees PARCEL_API_URL=$PARCEL_API_URL"));
        pb.environment().put("PARCEL_API_URL", "http://child.example");
        pb.redirectErrorStream(true);
        Process p = pb.start();
        System.out.print(new String(p.getInputStream().readAllBytes()));
        p.waitFor();
        System.out.println("parent still sees = " + System.getenv("PARCEL_API_URL"));
    }
}

```

```bash
java -Dparcel.empty= -cp out dev.ggorantala.parcel.Traps

```

```log
-- trap 1: Boolean.getBoolean reads a PROPERTY NAMED 'true'?
Boolean.getBoolean("parcel.verbose") with TRUE = true
Boolean.getBoolean("parcel.verbose") with yes  = false
Boolean.getBoolean("true")                      = false
-- trap 2: Integer.getInteger swallows a bad number
Integer.getInteger = 3
-- trap 3: empty -D value is an empty string, not null
[]
-- trap 4: setProperty does not change the environment, and env can't be changed
getenv(PARCEL_API_URL) = null
-- trap 5: a child process gets a COPY of the environment
child sees PARCEL_API_URL=http://child.example
parent still sees = null

```

- **Trap 1: `Boolean.getBoolean` doesn't parse a string.** It reads a system property by that name and returns `true` only if its value is "true" ignoring case. `-Dparcel.verbose=yes` gave `false` with no complaint, and `Boolean.getBoolean("true")` is `false` because there's no property called `true`. Cause: the name is misleading. Fix: use `Settings.bool`, which rejects anything but true or false.
- **Trap 2: `Integer.getInteger` swallows a bad number.** `-Dparcel.retries=three` returned the default `3`. Cause: it catches the parse error and returns the default. Fix: parse yourself and throw, as `Settings.integer` does.
- **Trap 3: an empty `-D` value is an empty string, not `null`.** `-Dparcel.empty=` printed `[]`. A `getProperty(...) != null` check treats that as "set". Fix: treat blank as missing, as `find` does.
- **Trap 4: `System.setProperty("PARCEL_API_URL", ...)` doesn't create an environment variable.** `getenv` still returned `null`. Cause: they're separate tables. Fix: nothing. If you want an environment variable in a *child* process, set it on the `ProcessBuilder`.
- **Trap 5: a child process gets a copy.** The child printed `http://child.example`, and the parent still saw `null`. That's the correct behaviour; it only surprises people who expect the parent to change.

Compile-time errors worth recognizing, from a scratch file I wrote to break things:

```log
E.java:1: error: incompatible types: String cannot be converted to int

```

That's `int n = System.getProperty("parcel.retries");`, which won't compile because `getProperty` returns a `String`. The `System.getenv().put(...)` line in the same file *does* compile, and only throws `UnsupportedOperationException` at run time. Compilers can't tell you the map is read-only.

Other snags to expect:

- **Your IDE run doesn't see the variable you exported in a terminal.** IDEs launched from a desktop icon don't inherit your shell's `export`. Set the variable in the run configuration (paths above) or start the IDE from the shell.
- **The value has a trailing newline or space.** I tested `PARCEL_API_URL=$'http://x\n'`; `Settings` stripped it. Without `strip()` the URL would have ended in a line break.
- **Names with dots or dashes in a shell.** `export parcel.api.url=x` is rejected by bash. That's why environment names use underscores.

## Before this reads real secrets

- Pass secrets by environment variable or a mounted file, not by `-D` flags. Flags show up in `ps` output for anyone on the machine. I didn't test that here; check your OS's process-listing docs.
- Don't dump `System.getenv()` or `System.getProperties()` into a log. Both contain things you didn't intend to publish. The `Settings.describe` approach logs only the keys you ask about.
- `.properties` files and `application.yml` are a third source worth adding once the list of settings grows. The same precedence idea works: add one more lookup function. For reading a file, see [how to read and write files with NIO.2](https://ggorantala.dev/how-to-read-write-files-nio-java/?ref=ggorantala.dev).
- In Spring Boot, relaxed binding already maps `PARCEL_API_URL` to `parcel.api.url` for you. I didn't build that here, so check the Spring Boot reference for the exact rules for your version.
- Containers: pass variables with `-e NAME=value` or an env file in Docker and Podman. I didn't run a container for this article.
- Java 17+ is fine for everything above. `Settings` uses nothing newer than Java 11 (`String.isBlank`, `strip`). On Java 8, replace those two methods.

If a value should be retried or cached, look at [how to implement retry logic with exponential backoff](https://ggorantala.dev/how-to-implement-retry-exponential-backoff-java/?ref=ggorantala.dev) next.

## Keep this list

- Read a setting through one class, never `System.getenv` scattered around.
- Decide the order once: property, then environment variable, then default.
- Treat blank as missing and strip whitespace.
- Parse numbers and booleans yourself and fail with the setting's name.
- Never print a secret; print only where it came from.
- Inject the lookups so tests don't need real environment variables.
- Remember that the environment is read-only in Java; only a child process gets changes.

## Questions about Java settings and environment variables

**How do I read an environment variable in Java?**  
Call `System.getenv("NAME")`. It returns the value as a `String`, or `null` if the variable isn't set. `System.getenv()` with no argument returns an unmodifiable map of all of them.

**How do I set an environment variable in Java?**  
You can't change your own process's environment. `System.getenv()` is read-only. You can set one for a child process through `ProcessBuilder.environment()`, or set it outside Java before starting the JVM.

**What's the difference between System.getenv and System.getProperty?**  
`getenv` reads operating-system environment variables the JVM inherited. `getProperty` reads JVM system properties, which come from `-D` flags, built-ins like `java.version`, and `System.setProperty` calls.

**How do I pass a -D value to a Java program?**  
Put it before the class or `-jar`: `java -Dparcel.timeout=30 -cp out Main`. After the class name it becomes a program argument instead.

**Is it safe to put secrets in environment variables?**  
Safer than `-D` flags, since flags appear in process listings. Still, avoid logging them and keep them out of images. A mounted secret file or a secret manager is better for production.

## References

- Java SE 21 API: `java.lang.System` (`getenv`, `getProperty`, `getProperties`, `setProperty`), `java.lang.Boolean.getBoolean`, `java.lang.Integer.getInteger`, `java.lang.ProcessBuilder.environment` (docs.oracle.com/en/java/javase/21/docs/api/)
- Java SE 21 `java` launcher documentation for the `-D` option