Just for an example, I went through the process of making a custom report. It went like this:
I copied bin/hledger-example-read.hs to bin/hledger-tagvals.hs:
#!/usr/bin/env stack
-- stack runghc --package hledger --package text
-- (to see setup progress output, add --verbosity info)
-- hledger-example-read - a short hledger stack script example
{-# LANGUAGE OverloadedStrings #-}
import Hledger.Cli.Script
import Data.Text qualified as T
import Data.Text.IO qualified as T
cmdmode = hledgerCommandMode (unlines
---------------------------standard terminal width-----------------------------
["example-hello"
,"Usage: hledger-example-hello [OPTS] [ARGS]"
,"or: hledger example-hello -- [OPTS] [ARGS]"
,"Examples:"
,"$ hledger-example-hello # do the thing"
,"$ hledger-example-hello --help # print help"
])
[] [generalflagsgroup1] [] ([], Just $ argsFlag "[ARGS]")
main = do
opts@CliOpts{reportspec_=rspec} <- getHledgerCliOpts cmdmode
withJournal opts $ \j -> do
putStrLn $ "successfully read journal: " <> journalFilePath j
And checked that it runs:
~/src/hledger$ hledger tagvals
successfully read journal: /Users/simon/adm/2026/2026.journal
Then I found the builtin tags command source (hledger/Hledger/Cli/Commands/Tags.hs)
and copied its tags function (and the non-Hledger imports),
and hid the tags import because I wanted to use the same name for my version,
and updated the help text:
#!/usr/bin/env stack
-- stack runghc --package hledger --package text --verbosity info
-- (to see setup progress output, add --verbosity info)
{-# LANGUAGE OverloadedStrings #-}
import Control.Monad.Fail qualified as Fail
import Data.Function ((&))
import Data.List (find)
import Data.List.Extra (nubSort)
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Safe
import System.Console.CmdArgs.Explicit
import Hledger.Cli.Script hiding (tags)
cmdmode = hledgerCommandMode (unlines
---------------------------standard terminal width-----------------------------
["tagvals"
,"Usage: hledger-tagvals [OPTS] [ARGS]"
,"or: hledger tagvals -- [OPTS] [ARGS]"
,":"
,"Print the unique tag name & value pairs."
])
[] [generalflagsgroup1] [] ([], Just $ argsFlag "[ARGS]")
main = do
opts@CliOpts{reportspec_=rspec} <- getHledgerCliOpts cmdmode
withJournal opts $ \j -> tags opts j
tags :: CliOpts -> Journal -> IO ()
tags opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=_q, _rsReportOpts=ropts}} j = do
printTitle ropts
let today = _rsDay rspec
args = listofstringopt "args" rawopts
-- For convenience/power, the first argument is a tag name regex,
-- separate from the main query arguments: hledger tags [TAGREGEX [QUERYARGS..]]
-- So we have to re-parse the query here. Overcomplicated ?
mtagpat <- mapM (either Fail.fail pure . toRegexCI . T.pack) $ headMay args
let
values = boolopt "values" rawopts
parsed = boolopt "parsed" rawopts
empty = empty_ ropts
querystr = map T.pack $ drop 1 args
query <- either usageError (return . fst) $ parseQueryList today querystr
let
q = simplifyQuery $ And [queryFromFlags ropts, query]
txns = filter (q `matchesTransaction`) $ jtxns $ journalApplyValuationFromOpts rspec j
accts =
-- also search for tags in matched account declarations,
-- unless there is a query for something transaction-specific, like date: or amt:.
if dbg5 "queryIsTransactionRelated" $ queryIsTransactionRelated $ dbg4 "q" q
then []
else filter (matchesAccountExtra (journalAccountType j) (journalInheritedAccountTags j) q) $
map fst $ jdeclaredaccounts j
-- bit of a mess.
used = dbg5 "used" $ concatMap (journalAccountTags j) accts ++ concatMap transactionAllTags txns
declared' = dbg5 "declared'" $ map (,"") $ journalTagsDeclared j
filtereddeclared = dbg5 "filtereddeclared'" $ filter (q `matchesTag`) declared'
(usednames, declarednames) = (map fst used, map fst filtereddeclared)
unused = dbg5 "unused" $ filter (not . (`elem` usednames) . fst) filtereddeclared
undeclared = dbg5 "undeclared" $ filter (not . (`elem` declarednames) . fst) used
all' = dbg5 "all''" $ filtereddeclared <> used
found = dbg5 "found" $ foundtag
where
-- First find the name, then the first occurrence of that tag.
-- So that --values and --parsed still work with --find (in some reasonably stable way).
alltags = declared' <> used
allnames = dbg5 "allnames" $ nubSort $ map fst alltags
foundname = dbg5 "foundname" $ findMatchedByArgument rawopts "tag name" allnames
foundtag = find ((==foundname).fst) alltags
& fromMaybe (error' "tags: could not find a tag's first occurrence") -- PARTIAL: should not happen because allnames and alltags correspond
tags' =
case declarablesSelectorFromOpts opts of
Nothing -> all'
Just Used -> used
Just Declared -> declared'
Just Undeclared -> undeclared
Just Unused -> unused
Just Find -> [found]
results =
(if parsed then id else nubSort)
[ r
| (t,v) <- tags'
, maybe True (`regexMatchText` t) mtagpat
, let r = if values then v else t
, not (values && T.null v && not empty)
]
mapM_ T.putStrLn results
Now I had my own version of the tags command:
$ cat a.j
2026-07-12
(assets) 1 ; vehicle:A
2026-07-12
(assets) 1 ; vehicle:AA
2026-07-12
(assets) 1 ; vendor:B
2026-07-12
(assets) 1 ; vendor:B
$ hledger -f a.j tagval
vehicle
vendor
And could replace line 87:
[ r
with:
[ t <> ": " <> v
$ hledger -f a.j tagvals
vehicle: A
vehicle: AA
vendor: B
vendor: BB
And compile it for speed (using the hledger project's installed packages):
~/src/hledger$ stack exec -- ghc bin/hledger-tagvals.hs
[1 of 2] Compiling Main ( bin/hledger-tagvals.hs, bin/hledger-tagvals.o ) [Source file changed]
[2 of 2] Linking bin/hledger-tagvals [Objects changed]
~/src/hledger$ hledger tagvals --help
tagvals [OPTIONS] [ARGS]
or: hledger tagvals -- [OPTS] [ARGS]
Examples:
$ hledger-tagvals # do the thing
$ hledger-tagvals --help # print help
See also hledger -h for general hledger options.
Solutions using just command-line tools, like the one above, are usually quicker to make and more terse. But the custom haskell script can be more powerful and more robust.