Reads are ~10x slower when a path element contains a digit

Open Beginner friendly
#857 0 comments 0 reactions 0 assignees View on GitHub

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 main as 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from lightbend/config

All issues in lightbend/config

Similar issues

More Java issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.