Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Performance

How fast hledger is, how to measure it, and what we have learned about making it faster.

Most measurements here use the synthetic sample journals in examples/, like examples/10ktxns-1kaccts.journal, generated by just samplejournals (see FILES). The large one, examples/100ktxns-1kaccts.journal, is 8 MB: 100k one-line transactions with 200k postings, 100k P price directives and 100k blank lines, using 1k accounts and 26 commodities; a third of the postings have a unit cost, and there are no balance assertions, assignments or comments. Real journals look different (more comments and multi-line transactions, few commodities), so check conclusions on a real journal too.

Performance across releases

hledger’s speed has varied over the years. hledger 1.25 (2022) was fast; later versions were slower (hledger 1.29-1.32.2 also had a performance bug with large files, #2153, fixed in hledger 1.40). The hledger 2 previews 1.99.1-1.99.4 are slower than hledger 1.x. In September 2026 hledger’s main branch was optimised, and is now the fastest hledger yet. This is not released yet; it will be in the next hledger 2 preview.

Here are some notable versions on the 100k journal, on a MacBook Pro M5 Pro in September 2026. Times are seconds for hledger -f examples/100ktxns-1kaccts.journal COMMAND, best of two runs (quickbench -w hledger-1.25,hledger-1.40,hledger-1.52,hledger-1.99.4,hledger -n2); txns/s is the throughput reported by hledger stats.

command1.251.401.521.99.4mainsince 1.25since 1.52since 1.99.4
stats2.703.954.295.962.0035% (1.4x)114% (2.1x)198% (3.0x)
balance2.683.924.065.802.1525% (1.2x)89% (1.9x)170% (2.7x)
print3.244.274.426.322.8414% (1.1x)56% (1.6x)123% (2.2x)
register71.9930.2220.7319.0214.17408% (5.1x)46% (1.5x)34% (1.3x)
txns/s37k25k23k *17k *52k41% (1.4x)126% (2.3x)206% (3.1x)

1.52 is the current hledger 1 release, and 1.99.4 the latest hledger 2 preview. So hledger main is about 3x faster than 1.99.4, 2x faster than 1.52, and 1.2x faster than 1.25, the previous speed king.

* hledger 1.51 to 1.99.4 measured stats’ elapsed time before computing the statistics and writing the report, so the txns/s they show is too high: about 10% on a 1k-transaction journal, 13% at 10k and 30% at 100k. These two figures are corrected (transactions divided by the whole run time). Other versions measure the whole run.

Earlier measurements, on a MacBook Air M1: hledger 1.25 processed about 25k transactions per second in 2022, and hledger 1.40 about 16k in 2024.

The September 2026 speedups came mostly from: skipping lot processing for journals which don’t use lots; dispatching each journal item on its first character, and checking the next character before trying optional syntax, instead of trying parsers and backtracking; inferring commodity styles in one pass; avoiding needless work in the transaction balancer; multiplying decimal numbers directly; and not rebuilding the journal for empty queries. The commits are listed by git log --grep='^perf:'.

When a release is made, update this table (and the “main” wording above).

Where the time goes

A snapshot from September 2026: hledger balance -f examples/100ktxns-1kaccts.journal --debug=1, about 2.3s in total (2.2s in a normal run):

phasetimeallocationnotes
startup + read0.05s70 MB
parse1.28s10.5 GBmore than half the run
journalReverse0.09s12 MBa GC pause landing here; the stage itself is trivial
journalAddAccountTypes0.04s161 MB
journalStyleAmounts0.22s197 MBrebuilds every posting to set display styles
journalTagCostsAndEquityAndMaybeInferCosts0.02s136 MBskipped per transaction unless conversion accounts are involved
journalBalanceTransactionsAndDeferAssertions0.10s415 MBthe second pass is skipped (no assertions or assignments)
journalInferCommodityStyles0.05s61 MB
journalInferMarketPricesFromTransactions0.06s348 MBonly charged under the timer; only valuation uses these
balance command0.37s1.1 GBbuilding the account tree, rendering
garbage collection, spread over all of these0.69s12.6 GB allocated, 262 MB max residency, about 0.8 GB RSS

Register is much slower on this journal (15s) because it renders a running balance in 26 commodities for 200k lines; real journals have few commodities. A journal using lots adds work in the lot stages (about 0.4s for 1000 lot transactions added to the 100k journal).

Measuring

quickbench

quickbench times the commands listed in a file (just bench uses bench/bench.sh by default) and shows a table of results, optionally running each command with several different executables, side by side. Install it as described in DEVWORKFLOWS, then:

$ just bench                    # time the commands in bench/bench.sh, with the hledger in PATH
$ just bench -f bench/bench10k.sh -w hledger-1.30,hledger-1.31,hledger-1.32 -n2 -N2   # compare versions, best of 2 runs, twice

The other bench*.sh files in the bench/ directory are alternative command sets, eg for many accounts, many transactions, or comparing with Ledger. quickbench needs the executables to be in PATH.

Throughput

hledger stats reports transactions per second. These recipes use it to show throughput at various data sizes:

$ just bench-throughput EXE      # with the given hledger executable
$ just bench-throughput-dev      # with the current dev build
$ just bench-throughput-recent   # with recent installed hledger versions

Performance test

just perftest runs hledger/test/_perf.test, which logs hledger stats throughput to perf.log (kept locally, for spotting changes over time), tagging each line with the machine’s CPU model, and fails if throughput is below a threshold.

Phase timings

Running any hledger command with --debug=1 (or higher) reports on stderr the run time and memory allocation of each phase: reading and parsing the data, each stage of journal finalising, and the command itself (see dbgTime in Hledger.Utils.Debug). Each stage’s result is fully evaluated to measure it (and the cost of that evaluation is excluded), so this describes a strict evaluation of the pipeline, but the totals closely match normal runs. One consequence: work that a normal run leaves unevaluated is charged too, eg inferred market prices, which only valuation uses, appear as a cost of every command. Garbage collection pauses are charged to whichever stage is running. This is the best tool for judging an optimisation.

Profiling

just hledgerprof builds a profiling-enabled bin/hledgerprof, then just quickprof CMD runs a hledger command on a sample journal and shows the profile (using profiterole). See just h prof for related recipes. With GHC 9.14, stack --profile fails (a compiler panic while building tls); instead build with stack-prof.yaml, which works around that and keeps its own .stack-prof work dir, as described in its header.

Use profiles to find candidates, and phase timings or quickbench to judge them. Profiling inflates small hot functions (source position calculation looked like 8% of the run, but removing it saved 4%) and shifts the shares of the rest. With megaparsec’s continuation-passing parsers, the cost shown beneath a parser in the profile tree mostly belongs to its continuation (everything parsed after it), so read the individual columns, or measure directly.

Package benchmark

hledger/bench/bench.hs is the hledger package’s benchmark suite: it calls the library directly, timing a journal read and the print, register and balance reports (quick timings by default, or criterion measurements with --criterion). It is currently disabled (buildable: false in hledger/package.yaml) to save build time, so stack bench hledger does nothing; to use it, enable it there.

Tips

  • Allocation is the most reliable signal: it is deterministic, and it tracks garbage collection cost. Wall clock time varies by a few percent between runs on a typical machine, so rerun before believing a small change.
  • To measure one kind of journal line, split the journal by line kind; eg a copy without price directives (grep -v '^P ') and one with only them showed the price directives costing a quarter of the parse time.
  • hledger +RTS -s -RTS shows the runtime system’s memory and GC statistics. Other runtime flags (-A, -F, -xn, heap profiling) are refused by the shipped binary; to experiment with them, build in a scratch work dir with -rtsopts, eg stack --work-dir .stack-rtsopts build --ghc-options=-rtsopts hledger.

What we have learned

Parsing

  • A failed parse attempt is expensive: megaparsec builds an error value, with sets of expected items and hints, for every failure, even one immediately backtracked by try or optional. A typical transaction and price directive used to fail dozens of attempts, and each one-line price directive allocated 47 KB. So optional syntax (signs, commodity symbols, exponents, costs, lot annotations, comments, status marks, codes, secondary dates, balance assertions, times in price directives) is checked by peeking at the next character or two, with Hledger.Utils.Parse’s peekChar, peekChars2 and peekAfterSpaces, and parsed only when present; manyWhile ends loops the same way. Journal items are dispatched on their first character, rather than trying each directive parser in turn. When adding syntax, keep to this pattern.
  • Labels (<?>) cost nothing measurable.
  • Most of what remains in the parser is megaparsec’s own machinery. Megaparsec 9.3.0 and later made hledger’s parser about 15% slower and 33% more allocating (the plain Text Stream instance now delegates through a newtype), reported as megaparsec#612. Rebuilt with megaparsec 9.2.2, hledger balance was about 10% faster (measured before the parser changes above).
  • Postings are built strictly while parsing, which lowers peak memory; fully forcing them with deepseq was slower, because it evaluates tags and comments that most reports never read.

Finalising

  • The lot stages run for any journal with lot features, even for reports that don’t mention lots, because they add gain postings and cost basis amounts that ordinary reports show (-I is the way to skip them). So lot work is skipped per transaction, for transactions without lot-related amounts, rather than per command.
  • Commodity style inference can’t be done just once: forecast transactions, auto postings and balancing add amounts later.
  • Applying display styles at report time instead of storing them on each amount was considered and rejected: every report would need to get it right.
  • Decimal’s (*) goes via Rational and 10^255 (about 6 KB and over a microsecond per multiplication), so amounts are multiplied with multiplyQuantities instead. Decimal’s (/) has the same problem, but is used only on rare paths.
  • Journal filtering with an empty query used to rebuild every transaction; it now returns the journal unchanged. Other report paths that filter with possibly-empty queries may have similar no-op passes.

Memory and garbage collection

  • Garbage collection time is mostly the copying of the live journal, not collection overhead: nursery sizes from 4 MB (the default) to 128 MB made no difference except to memory use.
  • The in-memory journal takes about 2.6 KB per transaction. Reducing that is the remaining GC lever: each amount carries its own copy of its display style, and account name texts are not shared between postings.
  • Some runtime flags trade memory for time: -xn (the non-moving collector) saved 10% of the run time but raised peak memory from 0.8 to 1.3 GB; -xn -F3 saved 13% for 24% more; -F4 6% for 32% more. Not adopted as defaults.
  • Compiling with -fexpose-all-unfoldings -fspecialise-aggressively gave only 4%, for a much longer build. Not adopted.

Reports

  • The balance report’s own cost is mostly building the account tree (a HashMap update and period data insertion per posting); rendering computes each amount’s width more than once.
  • Register’s cost is mostly output volume (rendering running balances).