How to organize all the tags in Hledger (including sub-tags)?

Hi everyone,

Using hledger tags only shows the top-level tags, and doesn't display any sub-tags.
For example, if I have tags like vehicle : A, vehicle : B, vendor : AA, vendor : BB,
running hledger tags only shows vehicle and vendor.

Is there a command in hledger that lists all tags along with their sub-tags?

OR
Do you have any other recommendations for managing tags?
Thanks!

We call those tag values, and tags --values will show them. But, there's no builtin report that will show tag names and values together, except print, I think.

So you could extract them like this, using ripgrep:

$ hledger print tag:. | rg '; *(\w+:.*)' -or '$1'
vehicle:A
vehicle:AA
vendor:B
vendor:BB

But tags --value won't be displayed simultaneously with tags.

Is there a command like accounts -tree that can display the hierarchical relationship between tags and values?

Example

vehicle

A

B

vendor

AA

BB

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.

For example, I added a tag on the same line as another (; vendor:B, verb:walk). Then I got your desired tree report by making account directives and using hledger accounts.

Using command line only:

~/src/hledger$ hledger -f a.j print tag:. | rg '; *(\w+:.*)' -or '$1' | sed 's/^/account /' | hledger -f- accounts -t
vehicle
  A
  AA
vendor
  B
  B, verb
    walk

Using a combination of haskell script and command line, it works properly:

~/src/hledger$ hledger -f a.j tagvals | sed 's/^/account /' | hledger -f- accounts -t
vehicle
   A
   AA
vendor
   B
verb
   walk

Or of course you could do it all in the script, at the cost of more programming.