diff --git a/CHANGES.md b/CHANGES.md
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -17,6 +17,8 @@
 For user-visible changes, see the hledger package changelog.
 
 
+# 1.50.2 2025-09-26
+
 # 1.50.1 2025-09-16
 
 # 1.50 2025-09-03
diff --git a/Hledger/Read/CsvReader.hs b/Hledger/Read/CsvReader.hs
--- a/Hledger/Read/CsvReader.hs
+++ b/Hledger/Read/CsvReader.hs
@@ -33,7 +33,8 @@
 import Hledger.Data
 import Hledger.Utils
 import Hledger.Read.Common (aliasesFromOpts, Reader(..), InputOpts(..), journalFinalise)
-import Hledger.Read.RulesReader (readJournalFromCsv)
+import Hledger.Read.RulesReader (readJournalFromCsv, getRulesFile, rulesEncoding, readRules)
+import Control.Monad.Trans (lift)
 
 --- ** doctest setup
 -- $setup
@@ -60,8 +61,10 @@
 -- This does not check balance assertions.
 parse :: SepFormat -> InputOpts -> FilePath -> Handle -> ExceptT String IO Journal
 parse sep iopts f h = do
-  let mrulesfile = mrules_file_ iopts
-  readJournalFromCsv (Right <$> mrulesfile) f h (Just sep)
+  rules <- readRules $ getRulesFile f (mrules_file_ iopts)
+  mencoding <- rulesEncoding rules
+  csvtext <- lift $ readHandlePortably' mencoding h
+  readJournalFromCsv rules f csvtext (Just sep)
   -- apply any command line account aliases. Can fail with a bad replacement pattern.
   >>= liftEither . journalApplyAliases (aliasesFromOpts iopts)
       -- journalFinalise assumes the journal's items are
diff --git a/Hledger/Read/RulesReader.hs b/Hledger/Read/RulesReader.hs
--- a/Hledger/Read/RulesReader.hs
+++ b/Hledger/Read/RulesReader.hs
@@ -29,13 +29,12 @@
   -- * Reader
   reader,
   -- * Misc.
-  readJournalFromCsv,
-  -- readRulesFile,
-  -- parseCsvRules,
-  -- validateCsvRules,
-  -- CsvRules,
   dataFileFor,
   rulesFileFor,
+  getRulesFile,
+  readRules,
+  rulesEncoding,
+  readJournalFromCsv,
   parseBalanceAssertionType,
   -- * Tests
   tests_RulesReader,
@@ -59,7 +58,7 @@
 import qualified Data.ByteString.Lazy as BL
 import qualified Data.Csv as Cassava
 import qualified Data.Csv.Parser.Megaparsec as CassavaMegaparsec
-import Data.Encoding (encodingFromStringExplicit)
+import Data.Encoding (encodingFromStringExplicit, DynEncoding)
 import Data.Either (fromRight)
 import Data.Functor ((<&>))
 import Data.List (elemIndex, mapAccumL, nub, sortOn)
@@ -168,7 +167,7 @@
   --  gives: file pattern, data cleaning/generating command, archive flag
 
   -- XXX higher-than usual logging priority for file reading (normally 6 or 7), to bypass excessive noise from elsewhere
-  rules <- readRulesFile $ dbg1 "reading rules file" rulesfile
+  rules <- readRules $ dbg1 "reading rules file" rulesfile
   let
     msourcearg = getDirective "source" rules
       -- Nothing -> error' $ rulesfile ++ " source rule must specify a file pattern or a command"
@@ -219,7 +218,7 @@
     (Nothing, _)   -> return ()
 
   -- 5. read raw, cleaned or generated data
-  --  needs: file pattern, data file, data command
+  --  needs: file pattern, data file, optional data file encoding, data command
   --  gives: clean data (possibly empty)
 
   mexistingdatafile <- maybe (return Nothing) (\f -> liftIO $ do
@@ -233,9 +232,10 @@
       return ""
 
     -- file found, and maybe a data cleaning command
-    (_, Just f,  mc) ->  -- trace "file found" $ 
+    (_, Just f,  mc) -> do  -- trace "file found" $
+      mencoding <- rulesEncoding rules
       liftIO $ do
-        raw <- openFileOrStdin f >>= readHandlePortably
+        raw <- openFileOrStdin f >>= readHandlePortably' mencoding
         maybe (return raw) (\c -> runCommandAsFilter rulesfile (dbg0Msg ("running: "++c) c) raw) mc
 
     -- no file pattern, but a data generating command
@@ -247,12 +247,11 @@
       error' $ rulesfile ++ " source rule must specify a file pattern or a command"
 
   -- 6. convert the clean data to a (possibly empty) journal
-  --  needs: clean data, rules, rules file, data file if any
+  --  needs: clean data, rules, data file if any
   --  gives: journal
 
   j <- do
-    cleandatah <- liftIO $ inputToHandle cleandata
-    readJournalFromCsv (Just $ Left rules) (fromMaybe "(cmd)" mdatafile) cleandatah Nothing
+    readJournalFromCsv rules (fromMaybe "(cmd)" mdatafile) cleandata Nothing
     -- apply any command line account aliases. Can fail with a bad replacement pattern.
     >>= liftEither . journalApplyAliases (aliasesFromOpts iopts)
         -- journalFinalise assumes the journal's items are
@@ -389,15 +388,37 @@
 rulesFileFor :: FilePath -> FilePath
 rulesFileFor = (++ ".rules")
 
+-- | Return the given rules file path, or if none is given,
+-- the default rules file for the given csv file;
+-- or if the csv file is "-", raise an error.
+getRulesFile :: FilePath -> Maybe FilePath -> FilePath
+getRulesFile csvfile mrulesfile =
+  case mrulesfile of
+    Nothing | csvfile == "-" ->
+      error' "please use --rules when reading CSV from stdin"  -- PARTIAL
+        -- XXX is this bad ? everything else here uses ExceptT
+    Nothing -> rulesFileFor csvfile
+    Just f -> f
+
 -- | An exception-throwing IO action that reads and validates
 -- the specified CSV rules file (which may include other rules files).
-readRulesFile :: FilePath -> ExceptT String IO CsvRules
-readRulesFile f =
+readRules :: FilePath -> ExceptT String IO CsvRules
+readRules f =
   liftIO (do
     dbg6IO "using conversion rules file" f
     readFilePortably f >>= expandIncludes (takeDirectory f)
   ) >>= either throwError return . parseAndValidateCsvRules f
 
+-- | Read the encoding specified by the @encoding@ rule, if any.
+-- Or throw an error if an unrecognised encoding is specified.
+rulesEncoding :: CsvRules -> ExceptT String IO (Maybe DynEncoding)
+rulesEncoding rules = do
+  case T.unpack <$> getDirective "encoding" rules of
+    Nothing     -> return Nothing
+    Just encstr -> case encodingFromStringExplicit $ dbg4 "encoding name" encstr of
+      Nothing  -> throwError $ "Invalid encoding: " <> encstr
+      Just enc -> return . Just $ dbg4 "encoding" enc
+
 -- | Inline all files referenced by include directives in this hledger CSV rules text, recursively.
 -- Included file paths may be relative to the directory of the provided file path.
 -- Unlike with journal files, this is done as a pre-parse step to simplify the CSV rules parser.
@@ -1167,26 +1188,11 @@
 --
 -- 4. Return the transactions as a Journal.
 --
-readJournalFromCsv :: Maybe (Either CsvRules FilePath) -> FilePath -> Handle -> Maybe SepFormat -> ExceptT String IO Journal
-readJournalFromCsv Nothing "-" h _ = lift (hClose h) *> throwError "please use --rules when reading CSV from stdin"
-readJournalFromCsv merulesfile csvfile csvhandle sep = do
+readJournalFromCsv :: CsvRules -> FilePath -> Text -> Maybe SepFormat -> ExceptT String IO Journal
+readJournalFromCsv rules csvfile csvtext sep = do
     -- for now, correctness is the priority here, efficiency not so much
 
-    rules <- case merulesfile of
-      Just (Left rs)         -> return rs
-      Just (Right rulesfile) -> readRulesFile rulesfile
-      Nothing                -> readRulesFile $ rulesFileFor csvfile
     dbg6IO "csv rules" rules
-
-    -- read csv while being aware of the encoding
-    mencoding <- do
-      -- XXX higher-than usual debug level for file reading to bypass excessive noise from elsewhere, normally 6 or 7
-      case T.unpack <$> getDirective "encoding" rules of
-        Just rawenc -> case encodingFromStringExplicit $ dbg4 "raw-encoding" rawenc of
-          Just enc -> return . Just $ dbg4 "encoding" enc
-          Nothing -> throwError $ "Invalid encoding: " <> rawenc
-        Nothing  -> return Nothing
-    csvtext <- lift $ readHandlePortably' mencoding csvhandle
 
     -- convert the csv data to lines and remove all empty/blank lines
     let csvlines1 = dbg9 "csvlines1" $ filter (not . T.null . T.strip) $ dbg9 "csvlines0" $ T.lines csvtext
diff --git a/Hledger/Utils/IO.hs b/Hledger/Utils/IO.hs
--- a/Hledger/Utils/IO.hs
+++ b/Hledger/Utils/IO.hs
@@ -341,7 +341,8 @@
         noencoding = map toLower enc == "ascii"
         msg = unlines $ [
             rstrip $ show ex
-          , "Some text could not be decoded with the system text encoding, " <> enc <> "."
+          , "Some text could not be decoded with the system's text encoding, " <> enc
+          , "(or, the text encoding specified by CSV rules)."
           ] ++
           if noencoding
           then [
diff --git a/hledger-lib.cabal b/hledger-lib.cabal
--- a/hledger-lib.cabal
+++ b/hledger-lib.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           hledger-lib
-version:        1.50.1
+version:        1.50.2
 synopsis:       A library providing the core functionality of hledger
 description:    This library contains hledger's core functionality.
                 It is used by most hledger* packages so that they support the same
@@ -148,7 +148,7 @@
     , extra >=1.7.11
     , file-embed >=0.0.10
     , filepath
-    , hashtables >=1.2.3.1
+    , hashtables >=1.2.3.1 && <1.3 || >=1.4.0
     , lucid
     , megaparsec >=7.0.0 && <9.8
     , microlens >=0.4
@@ -208,7 +208,7 @@
     , extra >=1.7.11
     , file-embed >=0.0.10
     , filepath
-    , hashtables >=1.2.3.1
+    , hashtables >=1.2.3.1 && <1.3 || >=1.4.0
     , lucid
     , megaparsec >=7.0.0 && <9.8
     , microlens >=0.4
@@ -269,7 +269,7 @@
     , extra >=1.7.11
     , file-embed >=0.0.10
     , filepath
-    , hashtables >=1.2.3.1
+    , hashtables >=1.2.3.1 && <1.3 || >=1.4.0
     , hledger-lib
     , lucid
     , megaparsec >=7.0.0 && <9.8
