DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Git 2.55's reftable backend creates 10,000 refs in 40ms instead of 650ms

I created 150 branches on the same repository at once, each from its own git update-ref process, on a repository using Git's reftable backend. Fifty-six of them succeeded. The other ninety-four printed this and exited non-zero:

fatal: update_ref failed for ref 'refs/heads/par-17': cannot lock references
Enter fullscreen mode Exit fullscreen mode

The same 150 processes against a repository using the old files backend all succeeded, every time I tried it. That is the part of this story nobody mentions when they talk about reftable being faster.

Git 2.55.0 (released 29 June 2026) ships the reftable ref storage format that the 2.51 release notes describe as "matured enough" that Git 3.0 will make it the default for new repositories. Instead of one small file per branch or tag under .git/refs/, reftable stores the whole ref database as a small number of sorted, binary-searchable table files under .git/reftable/. I wanted to know what that actually costs and saves, not just what the release notes claim.

Setting this up

Ubuntu's packaged Git is 2.43.0, which predates reftable's finished state, so I built 2.55.0 from the source tarball on kernel.org (./configure && make, about 53 seconds on four cores). I used that single binary for every comparison below and only changed --ref-format, so nothing here conflates a ref-backend difference with a Git-version difference. All numbers are from a Docker container on one machine; treat the absolute milliseconds as this-machine numbers and the ratios as the interesting part.

Writing refs

I built a commit, then fed both backends the same batch of update refs/heads/branch-NNNNNN <sha> lines through git update-ref --stdin, run against a freshly initialised bare repository each time.

refs files backend reftable backend
10,000 436–650ms (3 runs) 39–42ms (3 runs)
50,000 2.1s–12.4s (3 runs) 199–213ms (3 runs)

The reftable numbers are boringly consistent. The files-backend numbers at 50,000 refs are not: three runs on the same freshly-initialised repository gave 2.1, 5.3 and 12.4 seconds. Creating tens of thousands of loose files in one directory appears to have genuinely unpredictable tail latency on top of being slower on average, and reftable simply doesn't have that failure mode because it isn't writing one file per ref.

Disk use tells the same story more starkly. At 10,000 refs, .git/refs/ held 40MB across 10,000 files (mostly filesystem block overhead for 41-byte files); .git/reftable/ held one 266KB table plus a 43-byte list, 272KB total. At 50,000 refs it was 198MB against 1.4MB.

The number that argues against the headline

If bulk writes are 10–60x faster, I expected reads to follow. They didn't, not by nearly that much. git for-each-ref over the 10,000-ref repositories: 180–183ms (files) against 132–134ms (reftable), about 1.4x. At 50,000 refs: 897–943ms against 636–714ms, about 1.3x.

I also tried to reproduce the specific claim in the 2.51 release notes, that reftable makes git fetch 22x faster and git push 18x faster on a 10,000-ref repository. I cloned with --mirror, then again with --no-local to force the real ref-advertisement code path instead of a local-transport shortcut, and separately timed git ls-remote. Best case, at 50,000 refs, ls-remote went from 342–376ms to 73–189ms: real, but a 2–4x gain, not 22x. At 10,000 refs the gap nearly disappeared, 174–178ms against 119–169ms. I could not get anywhere near the official multiplier on one machine over file://. My best guess is that the release notes' benchmark measures a network transport where fixed per-round-trip cost is much smaller relative to ref-advertisement cost than it is here, and I'd treat the 22x/18x figures as measured on a specific rig rather than a number that travels.

One more read-side check: reftable's other selling point is "atomic reference transactions." I sent both backends a four-update batch through update-ref --stdin where one update's expected old value didn't match reality. Both backends rejected the entire batch and applied none of it, identically. update-ref --stdin has been atomic-or-nothing in the files backend for years, so this isn't a difference reftable introduces for anyone already using batched updates.

Where reftable refuses

The concurrency result at the top of this post is the one I'd actually plan around. I ran it several times to be sure the first result wasn't a fluke, at three different levels of concurrency, always the same repository freshly initialised, always distinct target refs so there was no real conflict for Git to detect:

concurrent writers files backend reftable backend (default)
50 50/50 succeed, 37–43ms 50/50 succeed, 74–108ms
100 not retested (see below) 56/100 succeed, both trials
150 150/150 succeed, 106–119ms 54–70/150 succeed, across 5 trials

Under 50 concurrent writers reftable is just slower, which is expected: every write has to lock a single tables.list file and append to a shared stack, where the files backend locks one file per ref and lets unrelated branches proceed independently. Somewhere between 50 and 100 writers that stops being "slower" and starts being "fails." At 100 and 150 concurrent writers I saw failure rates between 30% and 63% across seven separate trials, never zero, never total, always with the same error: cannot lock references.

cat .git/reftable/tables.list shows why. Under normal sequential load it holds one to three entries; Git's geometric compaction (reftable.geometricFactor, default 2) keeps folding small tables back together after almost every write. That compaction step needs the same lock every writer needs, so a burst of genuinely simultaneous writers queues up for one file, and Git's default patience for that queue is short.

The workaround, and its price

The relevant setting is reftable.lockTimeout, documented as "Value 0 means not to retry at all; -1 means to try indefinitely. Default is 100." I set it to 5000 and reran the 150-writer test three times: 150/150 succeeded every time, zero errors. It also took 1.05–1.15 seconds, against the files backend's 106–119ms for the identical job. The workaround removes the failures. It does not remove the underlying serialisation; it just makes everyone queue politely instead of a third of them giving up.

If your workload is "one process pushes to one repository," this never bites. If it's "CI creates a branch per job and several jobs finish at once," or "several people push new branches within the same second," it's worth checking your Git version's default reftable.lockTimeout before switching a busy repository to reftable, not after.

Migrating an existing repository

git refs migrate --ref-format=reftable converts a repository in place. On the 10,000-ref files repository from above it took 168ms and every ref came through intact. It refused outright on a repository with a second worktree attached:

error: migrating repositories with worktrees is not supported yet
Enter fullscreen mode Exit fullscreen mode

More subtly, migration rewrites .git/HEAD to the literal text ref: refs/heads/.invalid, even when the real branch (refs/heads/master, pointing at a real commit) still resolves correctly through git symbolic-ref HEAD, git rev-parse HEAD, git branch and a fresh git clone. This is documented and deliberate (Git's Documentation/technical/reftable.adoc): the dummy file exists so that tools which only check "does this directory look like a Git repository" still work, without leaking a real branch name into a file format that predates reftable's own bookkeeping. It does mean that anything reading .git/HEAD directly as a text file, rather than asking Git, will get .invalid back after a migration.

What I got wrong on the way

My first attempt at the atomic-transaction test used an all-zero SHA as the "expected old value" for a ref that didn't exist yet. That's the correct sentinel for "this ref must not already exist," not a conflict, so both backends happily applied the whole batch and I nearly wrote down "no atomicity difference, and also no rejection behaviour at all," which would have been wrong on the second half. I only caught it by pre-creating the ref and deliberately asking for the wrong existing value.

I also nearly missed the concurrency result entirely. My very first 150-writer run against reftable had only two failures out of 150, which looked like a rounding error I could ignore. Running it five more times over the next few minutes turned up 30 to 63 failures each time. Whatever was quiet on that first run (a warm page cache, low load, plain luck) wasn't representative, and one clean run is not evidence of anything when you're testing a queue.

Run it yourself

This needs Git built with reftable support; check with git init --ref-format=reftable /tmp/x. Ubuntu 24.04's packaged 2.43.0 doesn't have it, so building from source is the reliable route:

curl -LO https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.55.0.tar.gz
tar xzf git-2.55.0.tar.gz && cd git-2.55.0
make configure && ./configure --prefix=/opt/git-2.55.0
make -j"$(nproc)" && sudo make install
export PATH=/opt/git-2.55.0/bin:$PATH
Enter fullscreen mode Exit fullscreen mode

Then the concurrency test that produced the headline failure:

git init --bare --ref-format=reftable /tmp/rt-test
sha=$(git -C /tmp/rt-test hash-object -t commit --stdin -w <<< "irrelevant")  # or copy a real commit's objects in
for i in $(seq 1 150); do
  git -C /tmp/rt-test update-ref "refs/heads/par-$i" "$sha" 2>>/tmp/errs.log &
done
wait
grep -c "cannot lock references" /tmp/errs.log
Enter fullscreen mode Exit fullscreen mode

Compare against git init --bare --ref-format=files /tmp/files-test with the same loop, and against setting git -C /tmp/rt-test config reftable.lockTimeout 5000 before rerunning.

If you're running a repository that's mostly one writer at a time, reftable's bulk-write speed and disk footprint are a straightforward win and git refs migrate --ref-format=reftable is cheap enough to just try. If several processes create refs on the same repository around the same moment, run your own concurrency test at the scale you actually see before migrating, and check what reftable.lockTimeout is set to either way.

Top comments (1)

Collapse
 
kanunilabs profile image
KanuniLabs

the concurrency result is probably the most interesting part here. the bulk write numbers make reftable look like an obvious upgrade, but 150 concurrent writers turning into %30–63 failures changes the picture quite a bit.