Reads are ~10x slower when a path element contains a digit
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 76/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Tech stack
- java
- Domain
- performance
Research direction
Start in config/src/main/java/com/typesafe/config/impl/PathParser.java at looksUnsafeForFastParser and trace speculativeFastParsePath into parsePath. Run the existing test suite, then verify digit-containing paths use the fast path without changing numeric-path or invalid-path behavior. Done means reads such as prop50 avoid the full tokenizer while trailing-dot and other rejected paths retain their current behavior.
Written by the indexing model from the issue text.
Description
Hi, I'm creating my own config library and I decided to perform benchmarks against other libraries. While running the benchmark I discovered a pathological case in this library, which makes read speeds substantially slower than they need to be. Rather than publish information that is technically true but potentially misleading, I'm submitting a bug report here. Sorry in advance, as the below is AI-written (if you care about such things). It does point you exactly where you need to go.
Summary
Config.getInt, getString and friends are roughly 10x slower when the
path contains a digit than when it does not, for the same value in the same
config. getInt("alphaValue") takes ~20 ns; getInt("prop50") takes ~187 ns.
The cause is in PathParser.looksUnsafeForFastParser, whose character loop
accepts a-z, A-Z, _, - and . but not 0-9. Any path containing a
digit is therefore declared "unsafe", speculativeFastParsePath returns null,
and the full Tokenizer plus parsePathExpression runs on every read.
This is easy to miss because it depends on the key name rather than the value
or the accessor, but digits in configuration keys are common - oauth2Client,
worker1, s3Bucket, db2Url, ipv6Enabled.
Version
- config 1.4.3 (also present on
mainas of August 2026) - JDK 26, macOS, Apple silicon
- Reproduced both under JMH and with a plain timing loop
Measurements
JMH, 2 forks, 5 warmup and 5 measurement iterations of 1 second:
Benchmark Mode Cnt Score Error Units
getInt("alphaValue") avgt 10 19.583 ± 0.915 ns/op
getInt("prop50") avgt 10 186.642 ± 14.675 ns/op
The same shape appears for other accessors, so it is the path and not the
value type:
getInt("alpha") no digits 10.9 ns
getInt("prop50") has digits 196.0 ns
getString("beta") no digits 17.4 ns
getString("str50") has digits 163.6 ns
For comparison, the same reads against a plain HashMap are ~2 ns, so the
digit case is spending roughly 180 ns per read on path parsing alone.
Reproducer
import com.typesafe.config.*;
import java.nio.file.*;
import java.util.List;
public class DigitPathRepro {
static long sink = 0;
static double time(String label, java.util.function.LongSupplier op) {
for (int i = 0; i < 500_000; i++) sink += op.getAsLong(); // warm up
long n = 2_000_000, t0 = System.nanoTime();
for (int i = 0; i < n; i++) sink += op.getAsLong();
double ns = (System.nanoTime() - t0) / (double) n;
System.out.printf("%-28s %7.1f ns/op%n", label, ns);
return ns;
}
public static void main(String[] args) throws Exception {
Path f = Files.createTempFile("repro", ".conf");
Files.write(f, List.of("alpha = 50", "prop50 = 50"));
Config c = ConfigFactory.parseFile(f.toFile()).resolve();
double fast = time("getInt(\"alpha\")", () -> c.getInt("alpha"));
double slow = time("getInt(\"prop50\")", () -> c.getInt("prop50"));
System.out.printf("%nthe digit path is %.1fx slower%n", slow / fast);
Files.deleteIfExists(f);
if (sink == Long.MIN_VALUE) System.out.println(sink);
}
}
Both keys hold the same value and are read with the same method. The only
difference is the digit in the name.
Cause
PathParser.looksUnsafeForFastParser:
for (int i = 0; i < len; ++i) {
char c = s.charAt(i);
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
lastWasDot = false;
continue;
} else if (c == '.') {
...
} else if (c == '-') {
...
} else {
return true; // <- any digit lands here
}
}
parsePath then falls back to the full tokenizer:
static Path parsePath(String path) {
Path speculated = speculativeFastParsePath(path);
if (speculated != null)
return speculated;
// ... StringReader, Tokenizer.tokenize, parsePathExpression
}
Since SimpleConfig.getInt and the other accessors call parsePath on every
invocation, the cost is paid per read rather than once.
Suggested fix
Accept digits in the character loop:
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| (c >= '0' && c <= '9') || c == '_') {
lastWasDot = false;
continue;
}
Why this looks safe
The concern would be that the fast path splits purely on . while the
tokenizer treats a numeric element as a number token, so the two might disagree
about paths with numeric segments. Comparing ConfigUtil.splitPath (the
current behaviour) against naive dot-splitting (what the fast path would
produce):
| path | splitPath today |
dot split | same? |
|---|---|---|---|
1.50 |
[1, 50] |
[1, 50] |
yes |
1.0 |
[1, 0] |
[1, 0] |
yes |
01.5 |
[01, 5] |
[01, 5] |
yes |
1e5 |
[1e5] |
[1e5] |
yes |
1.5e3 |
[1, 5e3] |
[1, 5e3] |
yes |
-1.5 |
[-1, 5] |
[-1, 5] |
yes |
1.2.3 |
[1, 2, 3] |
[1, 2, 3] |
yes |
v1.2 |
[v1, 2] |
[v1, 2] |
yes |
a.01 |
[a, 01] |
[a, 01] |
yes |
0x10 |
[0x10] |
[0x10] |
yes |
00 |
[00] |
[00] |
yes |
1. |
BadPath |
[1, ] |
no |
Only a trailing dot differs, and looksUnsafeForFastParser already rejects
that before the loop:
if (s.charAt(len - 1) == '.')
return true;
Leading dots and .. are likewise already rejected. So for every path that
would reach the loop, dot-splitting and the tokenizer appear to agree.
I have not run your test suite against this change, so it is worth confirming -
in particular that nothing depends on a number token being normalized during
path parsing, which is the one thing this table cannot rule out.
Notes
I could not find an existing issue covering this. The nearby ones I found -
#115,
#101 and
#644 - are about correctness
with numeric paths rather than the cost of parsing them, and
#330 is about resolve().
Apologies if I missed one.
For most applications this will not matter: reads happen once at startup and
180 ns is nothing. It shows up in code that reads config inside a loop or a
request path, where it is surprising because it depends on how the key is
spelled.
- Dominant language
- Java
- Stars
- 6.3k
- Forks
- 980
- PR merge metrics
- No merged PRs in 30d
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from lightbend/config
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
-
Difficulty 3/5 1-2 days Newbie friendliness 55/100
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 52/100
-
Difficulty 4/5 3-5 days Newbie friendliness 35/100
All issues in lightbend/config
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
infinispan/infinispan#18150 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
-
untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
opensearch-project/k-NN#3597 ·
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 82/100