diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,8 @@
 # Changelog
 
+- 0.2.5 (2026-05-16)
+  * Improves bech32 encode/decode performance by about 2x.
+
 - 0.2.4 (2025-12-28)
   * Adds an 'llvm' build flag and tests with GHC 9.10.3.
 
diff --git a/lib/Data/ByteString/Base32.hs b/lib/Data/ByteString/Base32.hs
--- a/lib/Data/ByteString/Base32.hs
+++ b/lib/Data/ByteString/Base32.hs
@@ -1,10 +1,5 @@
 {-# OPTIONS_HADDOCK prune #-}
 {-# LANGUAGE BangPatterns #-}
-{-# LANGUAGE BinaryLiterals #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE ViewPatterns #-}
 
 -- |
 -- Module: Data.ByteString.Base32
@@ -14,112 +9,27 @@
 --
 -- Unpadded base32 encoding & decoding using the bech32 character set.
 
--- this module is an adaptation of emilypi's 'base32' library
-
 module Data.ByteString.Base32 (
     -- * base32 encoding and decoding
     encode
   , decode
   ) where
 
-import Control.Monad (guard)
-import Data.Bits ((.|.), (.&.))
 import qualified Data.Bits as B
+import Data.Bits ((.&.), (.|.))
 import qualified Data.ByteString as BS
-import qualified Data.ByteString.Builder as BSB
-import qualified Data.ByteString.Builder.Extra as BE
+import Data.ByteString.Base32.Internal (enc_tab, dec_tab)
 import qualified Data.ByteString.Internal as BI
-import qualified Data.ByteString.Unsafe as BU
-import Data.Word (Word8, Word32, Word64)
+import Data.Word (Word8)
+import Foreign.ForeignPtr (withForeignPtr)
+import Foreign.Ptr (Ptr, plusPtr)
+import Foreign.Storable (peekElemOff, pokeElemOff)
+import System.IO.Unsafe (unsafeDupablePerformIO)
 
-fi :: (Integral a, Num b) => a -> b
+fi :: (Num a, Integral b) => b -> a
 fi = fromIntegral
 {-# INLINE fi #-}
 
-word32be :: BS.ByteString -> Word32
-word32be s =
-  (fi (s `BU.unsafeIndex` 0) `B.shiftL` 24) .|.
-  (fi (s `BU.unsafeIndex` 1) `B.shiftL` 16) .|.
-  (fi (s `BU.unsafeIndex` 2) `B.shiftL`  8) .|.
-  (fi (s `BU.unsafeIndex` 3))
-{-# INLINE word32be #-}
-
--- realization for small builders
-toStrict :: BSB.Builder -> BS.ByteString
-toStrict = BS.toStrict
-  . BE.toLazyByteStringWith (BE.safeStrategy 128 BE.smallChunkSize) mempty
-{-# INLINE toStrict #-}
-
-bech32_charset :: BS.ByteString
-bech32_charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
-
-word5 :: Word8 -> Maybe Word8
-word5 = \case
-  113 -> pure $! 0
-  112 -> pure $! 1
-  122 -> pure $! 2
-  114 -> pure $! 3
-  121 -> pure $! 4
-  57  -> pure $! 5
-  120 -> pure $! 6
-  56  -> pure $! 7
-  103 -> pure $! 8
-  102 -> pure $! 9
-  50  -> pure $! 10
-  116 -> pure $! 11
-  118 -> pure $! 12
-  100 -> pure $! 13
-  119 -> pure $! 14
-  48  -> pure $! 15
-  115 -> pure $! 16
-  51  -> pure $! 17
-  106 -> pure $! 18
-  110 -> pure $! 19
-  53  -> pure $! 20
-  52  -> pure $! 21
-  107 -> pure $! 22
-  104 -> pure $! 23
-  99  -> pure $! 24
-  101 -> pure $! 25
-  54  -> pure $! 26
-  109 -> pure $! 27
-  117 -> pure $! 28
-  97  -> pure $! 29
-  55  -> pure $! 30
-  108 -> pure $! 31
-  _   -> Nothing
-{-# INLINE word5 #-}
-
-arrange :: Word32 -> Word8 -> BSB.Builder
-arrange w32 w8 =
-  let mask = 0b00011111                                 -- low 5-bit mask
-      bech32_char = fi . BS.index bech32_charset . fi   -- word5 -> bech32
-
-      -- split 40 bits into 8 w5's
-      w5_0 = mask .&. (w32 `B.shiftR` 27) -- highest 5 bits
-      w5_1 = mask .&. (w32 `B.shiftR` 22)
-      w5_2 = mask .&. (w32 `B.shiftR` 17)
-      w5_3 = mask .&. (w32 `B.shiftR` 12)
-      w5_4 = mask .&. (w32 `B.shiftR` 07)
-      w5_5 = mask .&. (w32 `B.shiftR` 02)
-      -- combine lowest 2 bits of w32 with highest 3 bits of w8
-      w5_6 = mask .&. (w32 `B.shiftL` 03 .|. fi w8 `B.shiftR` 05)
-      -- lowest 5 bits of w8
-      w5_7 = mask .&. fi w8
-
-      -- get (w8) bech32 char for each w5, pack all into little-endian w64
-      !w64 = bech32_char w5_0
-         .|. bech32_char w5_1 `B.shiftL` 8
-         .|. bech32_char w5_2 `B.shiftL` 16
-         .|. bech32_char w5_3 `B.shiftL` 24
-         .|. bech32_char w5_4 `B.shiftL` 32
-         .|. bech32_char w5_5 `B.shiftL` 40
-         .|. bech32_char w5_6 `B.shiftL` 48
-         .|. bech32_char w5_7 `B.shiftL` 56
-
-  in  BSB.word64LE w64
-{-# INLINE arrange #-}
-
 -- | Encode a base256-encoded 'ByteString' as a base32-encoded
 --   'ByteString', using the bech32 character set.
 --
@@ -128,91 +38,101 @@
 encode
   :: BS.ByteString -- ^ base256-encoded bytestring
   -> BS.ByteString -- ^ base32-encoded bytestring
-encode dat = toStrict (go dat) where
-  bech32_char = fi . BS.index bech32_charset . fi
-
-  go bs@(BI.PS _ _ l)
-    | l >= 5 = case BS.splitAt 5 bs of
-        (chunk, etc) -> case BS.unsnoc chunk of
-          Nothing -> error "impossible, chunk length is 5"
-          Just (word32be -> w32, w8) -> arrange w32 w8 <> go etc
-    | l == 0 = mempty
-    | l == 1 =
-        let a = BU.unsafeIndex bs 0
-            t = bech32_char ((a .&. 0b11111000) `B.shiftR` 3)
-            u = bech32_char ((a .&. 0b00000111) `B.shiftL` 2)
-
-            !w16 = fi t
-               .|. fi u `B.shiftL` 8
-
-        in  BSB.word16LE w16
-    | l == 2 =
-        let a = BU.unsafeIndex bs 0
-            b = BU.unsafeIndex bs 1
-            t = bech32_char ((a .&. 0b11111000) `B.shiftR` 3)
-            u = bech32_char $
-                      ((a .&. 0b00000111) `B.shiftL` 2)
-                  .|. ((b .&. 0b11000000) `B.shiftR` 6)
-            v = bech32_char ((b .&. 0b00111110) `B.shiftR` 1)
-            w = bech32_char ((b .&. 0b00000001) `B.shiftL` 4)
-
-            !w32 = fi t
-               .|. fi u `B.shiftL` 8
-               .|. fi v `B.shiftL` 16
-               .|. fi w `B.shiftL` 24
-
-        in  BSB.word32LE w32
-    | l == 3 =
-        let a = BU.unsafeIndex bs 0
-            b = BU.unsafeIndex bs 1
-            c = BU.unsafeIndex bs 2
-            t = bech32_char ((a .&. 0b11111000) `B.shiftR` 3)
-            u = bech32_char $
-                      ((a .&. 0b00000111) `B.shiftL` 2)
-                  .|. ((b .&. 0b11000000) `B.shiftR` 6)
-            v = bech32_char ((b .&. 0b00111110) `B.shiftR` 1)
-            w = bech32_char $
-                      ((b .&. 0b00000001) `B.shiftL` 4)
-                  .|. ((c .&. 0b11110000) `B.shiftR` 4)
-            x = bech32_char ((c .&. 0b00001111) `B.shiftL` 1)
-
-            !w32 = fi t
-               .|. fi u `B.shiftL` 8
-               .|. fi v `B.shiftL` 16
-               .|. fi w `B.shiftL` 24
-
-        in  BSB.word32LE w32 <> BSB.word8 x
-    | l == 4 =
-        let a = BU.unsafeIndex bs 0
-            b = BU.unsafeIndex bs 1
-            c = BU.unsafeIndex bs 2
-            d = BU.unsafeIndex bs 3
-            t = bech32_char ((a .&. 0b11111000) `B.shiftR` 3)
-            u = bech32_char $
-                      ((a .&. 0b00000111) `B.shiftL` 2)
-                  .|. ((b .&. 0b11000000) `B.shiftR` 6)
-            v = bech32_char ((b .&. 0b00111110) `B.shiftR` 1)
-            w = bech32_char $
-                      ((b .&. 0b00000001) `B.shiftL` 4)
-                  .|. ((c .&. 0b11110000) `B.shiftR` 4)
-            x = bech32_char $
-                      ((c .&. 0b00001111) `B.shiftL` 1)
-                  .|. ((d .&. 0b10000000) `B.shiftR` 7)
-            y = bech32_char ((d .&. 0b01111100) `B.shiftR` 2)
-            z = bech32_char ((d .&. 0b00000011) `B.shiftL` 3)
-
-            !w32 = fi t
-               .|. fi u `B.shiftL` 8
-               .|. fi v `B.shiftL` 16
-               .|. fi w `B.shiftL` 24
-
-            !w16 = fi x
-               .|. fi y `B.shiftL` 8
+encode (BI.PS sfp soff l) = case enc_tab of
+  BI.PS tfp toff _ ->
+    let !outlen = (l * 8 + 4) `quot` 5
+    in  BI.unsafeCreate outlen $ \dst ->
+          withForeignPtr sfp $ \sp0 ->
+          withForeignPtr tfp $ \tp0 -> do
+            let !sp = sp0 `plusPtr` soff :: Ptr Word8
+                !tp = tp0 `plusPtr` toff :: Ptr Word8
+            encode_loop sp tp dst l 0 0
 
-        in  BSB.word32LE w32 <> BSB.word16LE w16 <> BSB.word8 z
+encode_loop
+  :: Ptr Word8 -> Ptr Word8 -> Ptr Word8
+  -> Int -> Int -> Int -> IO ()
+encode_loop !sp !tp !dst !len !i !j
+  | i + 5 <= len = do
+      a <- peekElemOff sp i
+      b <- peekElemOff sp (i + 1)
+      c <- peekElemOff sp (i + 2)
+      d <- peekElemOff sp (i + 3)
+      e <- peekElemOff sp (i + 4)
+      let !w0 = (a `B.shiftR` 3) .&. 0x1f
+          !w1 = (a `B.shiftL` 2 .|. b `B.shiftR` 6) .&. 0x1f
+          !w2 = (b `B.shiftR` 1) .&. 0x1f
+          !w3 = (b `B.shiftL` 4 .|. c `B.shiftR` 4) .&. 0x1f
+          !w4 = (c `B.shiftL` 1 .|. d `B.shiftR` 7) .&. 0x1f
+          !w5 = (d `B.shiftR` 2) .&. 0x1f
+          !w6 = (d `B.shiftL` 3 .|. e `B.shiftR` 5) .&. 0x1f
+          !w7 = e .&. 0x1f
+      peekElemOff tp (fi w0) >>= pokeElemOff dst j
+      peekElemOff tp (fi w1) >>= pokeElemOff dst (j + 1)
+      peekElemOff tp (fi w2) >>= pokeElemOff dst (j + 2)
+      peekElemOff tp (fi w3) >>= pokeElemOff dst (j + 3)
+      peekElemOff tp (fi w4) >>= pokeElemOff dst (j + 4)
+      peekElemOff tp (fi w5) >>= pokeElemOff dst (j + 5)
+      peekElemOff tp (fi w6) >>= pokeElemOff dst (j + 6)
+      peekElemOff tp (fi w7) >>= pokeElemOff dst (j + 7)
+      encode_loop sp tp dst len (i + 5) (j + 8)
+  | otherwise = encode_tail sp tp dst len i j
 
-    | otherwise =
-        error "impossible"
+encode_tail
+  :: Ptr Word8 -> Ptr Word8 -> Ptr Word8
+  -> Int -> Int -> Int -> IO ()
+encode_tail !sp !tp !dst !len !i !j = case len - i of
+  0 -> pure ()
+  1 -> do
+    a <- peekElemOff sp i
+    let !w0 = (a `B.shiftR` 3) .&. 0x1f
+        !w1 = (a `B.shiftL` 2) .&. 0x1f
+    peekElemOff tp (fi w0) >>= pokeElemOff dst j
+    peekElemOff tp (fi w1) >>= pokeElemOff dst (j + 1)
+  2 -> do
+    a <- peekElemOff sp i
+    b <- peekElemOff sp (i + 1)
+    let !w0 = (a `B.shiftR` 3) .&. 0x1f
+        !w1 = (a `B.shiftL` 2 .|. b `B.shiftR` 6) .&. 0x1f
+        !w2 = (b `B.shiftR` 1) .&. 0x1f
+        !w3 = (b `B.shiftL` 4) .&. 0x1f
+    peekElemOff tp (fi w0) >>= pokeElemOff dst j
+    peekElemOff tp (fi w1) >>= pokeElemOff dst (j + 1)
+    peekElemOff tp (fi w2) >>= pokeElemOff dst (j + 2)
+    peekElemOff tp (fi w3) >>= pokeElemOff dst (j + 3)
+  3 -> do
+    a <- peekElemOff sp i
+    b <- peekElemOff sp (i + 1)
+    c <- peekElemOff sp (i + 2)
+    let !w0 = (a `B.shiftR` 3) .&. 0x1f
+        !w1 = (a `B.shiftL` 2 .|. b `B.shiftR` 6) .&. 0x1f
+        !w2 = (b `B.shiftR` 1) .&. 0x1f
+        !w3 = (b `B.shiftL` 4 .|. c `B.shiftR` 4) .&. 0x1f
+        !w4 = (c `B.shiftL` 1) .&. 0x1f
+    peekElemOff tp (fi w0) >>= pokeElemOff dst j
+    peekElemOff tp (fi w1) >>= pokeElemOff dst (j + 1)
+    peekElemOff tp (fi w2) >>= pokeElemOff dst (j + 2)
+    peekElemOff tp (fi w3) >>= pokeElemOff dst (j + 3)
+    peekElemOff tp (fi w4) >>= pokeElemOff dst (j + 4)
+  4 -> do
+    a <- peekElemOff sp i
+    b <- peekElemOff sp (i + 1)
+    c <- peekElemOff sp (i + 2)
+    d <- peekElemOff sp (i + 3)
+    let !w0 = (a `B.shiftR` 3) .&. 0x1f
+        !w1 = (a `B.shiftL` 2 .|. b `B.shiftR` 6) .&. 0x1f
+        !w2 = (b `B.shiftR` 1) .&. 0x1f
+        !w3 = (b `B.shiftL` 4 .|. c `B.shiftR` 4) .&. 0x1f
+        !w4 = (c `B.shiftL` 1 .|. d `B.shiftR` 7) .&. 0x1f
+        !w5 = (d `B.shiftR` 2) .&. 0x1f
+        !w6 = (d `B.shiftL` 3) .&. 0x1f
+    peekElemOff tp (fi w0) >>= pokeElemOff dst j
+    peekElemOff tp (fi w1) >>= pokeElemOff dst (j + 1)
+    peekElemOff tp (fi w2) >>= pokeElemOff dst (j + 2)
+    peekElemOff tp (fi w3) >>= pokeElemOff dst (j + 3)
+    peekElemOff tp (fi w4) >>= pokeElemOff dst (j + 4)
+    peekElemOff tp (fi w5) >>= pokeElemOff dst (j + 5)
+    peekElemOff tp (fi w6) >>= pokeElemOff dst (j + 6)
+  _ -> pure ()  -- impossible: 0 <= len - i < 5
 
 -- | Decode a 'ByteString', encoded as base32 using the bech32 character
 --   set, to a base256-encoded 'ByteString'.
@@ -224,108 +144,166 @@
 decode
   :: BS.ByteString        -- ^ base32-encoded bytestring
   -> Maybe BS.ByteString  -- ^ base256-encoded bytestring
-decode = fmap toStrict . go mempty where
-  go acc bs@(BI.PS _ _ l)
-    | l < 8 = do
-        fin <- finalize bs
-        pure (acc <> fin)
-    | otherwise = case BS.splitAt 8 bs of
-        (chunk, etc) -> do
-           res <- decode_chunk chunk
-           go (acc <> res) etc
-
-finalize :: BS.ByteString -> Maybe BSB.Builder
-finalize bs@(BI.PS _ _ l)
-  | l == 0 = Just mempty
-  | otherwise = do
-      guard (l >= 2)
-      w5_0 <- word5 (BU.unsafeIndex bs 0)
-      w5_1 <- word5 (BU.unsafeIndex bs 1)
-      let w8_0 = w5_0 `B.shiftL` 3
-             .|. w5_1 `B.shiftR` 2
-
-      -- https://datatracker.ietf.org/doc/html/rfc4648#section-6
-      if | l == 2 -> do -- 2 w5's, need 1 w8; 2 bits remain
-             guard (w5_1 `B.shiftL` 6 == 0)
-             pure (BSB.word8 w8_0)
-
-         | l == 4 -> do -- 4 w5's, need 2 w8's; 4 bits remain
-             w5_2 <- word5 (BU.unsafeIndex bs 2)
-             w5_3 <- word5 (BU.unsafeIndex bs 3)
-             let w8_1 = w5_1 `B.shiftL` 6
-                    .|. w5_2 `B.shiftL` 1
-                    .|. w5_3 `B.shiftR` 4
-
-                 !w16 = fi w8_1
-                    .|. fi w8_0 `B.shiftL` 8
-
-             guard (w5_3 `B.shiftL` 4 == 0)
-             pure (BSB.word16BE w16)
-
-         | l == 5 -> do -- 5 w5's, need 3 w8's; 1 bit remains
-             w5_2 <- word5 (BU.unsafeIndex bs 2)
-             w5_3 <- word5 (BU.unsafeIndex bs 3)
-             w5_4 <- word5 (BU.unsafeIndex bs 4)
-             let w8_1 = w5_1 `B.shiftL` 6
-                    .|. w5_2 `B.shiftL` 1
-                    .|. w5_3 `B.shiftR` 4
-                 w8_2 = w5_3 `B.shiftL` 4
-                    .|. w5_4 `B.shiftR` 1
-
-                 w16  = fi w8_1
-                    .|. fi w8_0 `B.shiftL` 8
-
-             guard (w5_4 `B.shiftL` 7 == 0)
-             pure (BSB.word16BE w16 <> BSB.word8 w8_2)
-
-         | l == 7 -> do -- 7 w5's, need 4 w8's; 3 bits remain
-             w5_2 <- word5 (BU.unsafeIndex bs 2)
-             w5_3 <- word5 (BU.unsafeIndex bs 3)
-             w5_4 <- word5 (BU.unsafeIndex bs 4)
-             w5_5 <- word5 (BU.unsafeIndex bs 5)
-             w5_6 <- word5 (BU.unsafeIndex bs 6)
-             let w8_1 = w5_1 `B.shiftL` 6
-                    .|. w5_2 `B.shiftL` 1
-                    .|. w5_3 `B.shiftR` 4
-                 w8_2 = w5_3 `B.shiftL` 4
-                    .|. w5_4 `B.shiftR` 1
-                 w8_3 = w5_4 `B.shiftL` 7
-                    .|. w5_5 `B.shiftL` 2
-                    .|. w5_6 `B.shiftR` 3
-
-                 w32  = fi w8_3
-                    .|. fi w8_2 `B.shiftL` 8
-                    .|. fi w8_1 `B.shiftL` 16
-                    .|. fi w8_0 `B.shiftL` 24
-
-             guard (w5_6 `B.shiftL` 5 == 0)
-             pure (BSB.word32BE w32)
-
-         | otherwise -> Nothing
-
--- assumes length 8 input
-decode_chunk :: BS.ByteString -> Maybe BSB.Builder
-decode_chunk bs = do
-  w5_0 <- word5 (BU.unsafeIndex bs 0)
-  w5_1 <- word5 (BU.unsafeIndex bs 1)
-  w5_2 <- word5 (BU.unsafeIndex bs 2)
-  w5_3 <- word5 (BU.unsafeIndex bs 3)
-  w5_4 <- word5 (BU.unsafeIndex bs 4)
-  w5_5 <- word5 (BU.unsafeIndex bs 5)
-  w5_6 <- word5 (BU.unsafeIndex bs 6)
-  w5_7 <- word5 (BU.unsafeIndex bs 7)
-
-  let w40 :: Word64
-      !w40 = fi w5_0 `B.shiftL` 35
-         .|. fi w5_1 `B.shiftL` 30
-         .|. fi w5_2 `B.shiftL` 25
-         .|. fi w5_3 `B.shiftL` 20
-         .|. fi w5_4 `B.shiftL` 15
-         .|. fi w5_5 `B.shiftL` 10
-         .|. fi w5_6 `B.shiftL` 05
-         .|. fi w5_7
-      !w32 = fi (w40 `B.shiftR` 8)   :: Word32
-      !w8  = fi (0b11111111 .&. w40) :: Word8
+decode (BI.PS sfp soff l) = case l `rem` 8 of
+  1 -> Nothing
+  3 -> Nothing
+  6 -> Nothing
+  _ -> case dec_tab of
+    BI.PS tfp toff _ -> unsafeDupablePerformIO $ do
+      let !n = (l * 5) `B.shiftR` 3
+      fp <- BI.mallocByteString n
+      ok <- withForeignPtr fp  $ \dst ->
+            withForeignPtr sfp $ \sp0 ->
+            withForeignPtr tfp $ \tp0 -> do
+              let !sp = sp0 `plusPtr` soff :: Ptr Word8
+                  !tp = tp0 `plusPtr` toff :: Ptr Word8
+              decode_loop sp tp dst l 0 0 0
+      pure $! if ok then Just (BI.PS fp 0 n) else Nothing
 
-  pure $ BSB.word32BE w32 <> BSB.word8 w8
+decode_loop
+  :: Ptr Word8 -> Ptr Word8 -> Ptr Word8
+  -> Int -> Int -> Int -> Word8 -> IO Bool
+decode_loop !sp !tp !dst !len !i !j !acc
+  | i + 8 <= len = do
+      c0 <- peekElemOff sp i
+      c1 <- peekElemOff sp (i + 1)
+      c2 <- peekElemOff sp (i + 2)
+      c3 <- peekElemOff sp (i + 3)
+      c4 <- peekElemOff sp (i + 4)
+      c5 <- peekElemOff sp (i + 5)
+      c6 <- peekElemOff sp (i + 6)
+      c7 <- peekElemOff sp (i + 7)
+      n0 <- peekElemOff tp (fi c0)
+      n1 <- peekElemOff tp (fi c1)
+      n2 <- peekElemOff tp (fi c2)
+      n3 <- peekElemOff tp (fi c3)
+      n4 <- peekElemOff tp (fi c4)
+      n5 <- peekElemOff tp (fi c5)
+      n6 <- peekElemOff tp (fi c6)
+      n7 <- peekElemOff tp (fi c7)
+      let !v0 = n0 .&. 0x1f
+          !v1 = n1 .&. 0x1f
+          !v2 = n2 .&. 0x1f
+          !v3 = n3 .&. 0x1f
+          !v4 = n4 .&. 0x1f
+          !v5 = n5 .&. 0x1f
+          !v6 = n6 .&. 0x1f
+          !v7 = n7 .&. 0x1f
+          !b0 = (v0 `B.shiftL` 3) .|. (v1 `B.shiftR` 2)
+          !b1 = (v1 `B.shiftL` 6) .|. (v2 `B.shiftL` 1) .|.
+                (v3 `B.shiftR` 4)
+          !b2 = (v3 `B.shiftL` 4) .|. (v4 `B.shiftR` 1)
+          !b3 = (v4 `B.shiftL` 7) .|. (v5 `B.shiftL` 2) .|.
+                (v6 `B.shiftR` 3)
+          !b4 = (v6 `B.shiftL` 5) .|. v7
+      pokeElemOff dst j       b0
+      pokeElemOff dst (j + 1) b1
+      pokeElemOff dst (j + 2) b2
+      pokeElemOff dst (j + 3) b3
+      pokeElemOff dst (j + 4) b4
+      decode_loop sp tp dst len (i + 8) (j + 5)
+        (acc .|. n0 .|. n1 .|. n2 .|. n3 .|. n4 .|. n5 .|. n6 .|. n7)
+  | otherwise = decode_tail sp tp dst len i j acc
 
+decode_tail
+  :: Ptr Word8 -> Ptr Word8 -> Ptr Word8
+  -> Int -> Int -> Int -> Word8 -> IO Bool
+decode_tail !sp !tp !dst !len !i !j !acc = case len - i of
+  0 -> pure $! acc .&. 0x40 == 0
+  2 -> do
+    c0 <- peekElemOff sp i
+    c1 <- peekElemOff sp (i + 1)
+    n0 <- peekElemOff tp (fi c0)
+    n1 <- peekElemOff tp (fi c1)
+    let !v0 = n0 .&. 0x1f
+        !v1 = n1 .&. 0x1f
+        !b0 = (v0 `B.shiftL` 3) .|. (v1 `B.shiftR` 2)
+        -- canonical-form check: bits dropped from v1 must be zero
+        !slack = v1 `B.shiftL` 6
+    pokeElemOff dst j b0
+    pure $! (acc .|. n0 .|. n1) .&. 0x40 == 0 && slack == 0
+  4 -> do
+    c0 <- peekElemOff sp i
+    c1 <- peekElemOff sp (i + 1)
+    c2 <- peekElemOff sp (i + 2)
+    c3 <- peekElemOff sp (i + 3)
+    n0 <- peekElemOff tp (fi c0)
+    n1 <- peekElemOff tp (fi c1)
+    n2 <- peekElemOff tp (fi c2)
+    n3 <- peekElemOff tp (fi c3)
+    let !v0 = n0 .&. 0x1f
+        !v1 = n1 .&. 0x1f
+        !v2 = n2 .&. 0x1f
+        !v3 = n3 .&. 0x1f
+        !b0 = (v0 `B.shiftL` 3) .|. (v1 `B.shiftR` 2)
+        !b1 = (v1 `B.shiftL` 6) .|. (v2 `B.shiftL` 1) .|.
+              (v3 `B.shiftR` 4)
+        !slack = v3 `B.shiftL` 4
+    pokeElemOff dst j       b0
+    pokeElemOff dst (j + 1) b1
+    pure $! (acc .|. n0 .|. n1 .|. n2 .|. n3) .&. 0x40 == 0
+         && slack == 0
+  5 -> do
+    c0 <- peekElemOff sp i
+    c1 <- peekElemOff sp (i + 1)
+    c2 <- peekElemOff sp (i + 2)
+    c3 <- peekElemOff sp (i + 3)
+    c4 <- peekElemOff sp (i + 4)
+    n0 <- peekElemOff tp (fi c0)
+    n1 <- peekElemOff tp (fi c1)
+    n2 <- peekElemOff tp (fi c2)
+    n3 <- peekElemOff tp (fi c3)
+    n4 <- peekElemOff tp (fi c4)
+    let !v0 = n0 .&. 0x1f
+        !v1 = n1 .&. 0x1f
+        !v2 = n2 .&. 0x1f
+        !v3 = n3 .&. 0x1f
+        !v4 = n4 .&. 0x1f
+        !b0 = (v0 `B.shiftL` 3) .|. (v1 `B.shiftR` 2)
+        !b1 = (v1 `B.shiftL` 6) .|. (v2 `B.shiftL` 1) .|.
+              (v3 `B.shiftR` 4)
+        !b2 = (v3 `B.shiftL` 4) .|. (v4 `B.shiftR` 1)
+        !slack = v4 `B.shiftL` 7
+    pokeElemOff dst j       b0
+    pokeElemOff dst (j + 1) b1
+    pokeElemOff dst (j + 2) b2
+    pure $! (acc .|. n0 .|. n1 .|. n2 .|. n3 .|. n4) .&. 0x40 == 0
+         && slack == 0
+  7 -> do
+    c0 <- peekElemOff sp i
+    c1 <- peekElemOff sp (i + 1)
+    c2 <- peekElemOff sp (i + 2)
+    c3 <- peekElemOff sp (i + 3)
+    c4 <- peekElemOff sp (i + 4)
+    c5 <- peekElemOff sp (i + 5)
+    c6 <- peekElemOff sp (i + 6)
+    n0 <- peekElemOff tp (fi c0)
+    n1 <- peekElemOff tp (fi c1)
+    n2 <- peekElemOff tp (fi c2)
+    n3 <- peekElemOff tp (fi c3)
+    n4 <- peekElemOff tp (fi c4)
+    n5 <- peekElemOff tp (fi c5)
+    n6 <- peekElemOff tp (fi c6)
+    let !v0 = n0 .&. 0x1f
+        !v1 = n1 .&. 0x1f
+        !v2 = n2 .&. 0x1f
+        !v3 = n3 .&. 0x1f
+        !v4 = n4 .&. 0x1f
+        !v5 = n5 .&. 0x1f
+        !v6 = n6 .&. 0x1f
+        !b0 = (v0 `B.shiftL` 3) .|. (v1 `B.shiftR` 2)
+        !b1 = (v1 `B.shiftL` 6) .|. (v2 `B.shiftL` 1) .|.
+              (v3 `B.shiftR` 4)
+        !b2 = (v3 `B.shiftL` 4) .|. (v4 `B.shiftR` 1)
+        !b3 = (v4 `B.shiftL` 7) .|. (v5 `B.shiftL` 2) .|.
+              (v6 `B.shiftR` 3)
+        !slack = v6 `B.shiftL` 5
+    pokeElemOff dst j       b0
+    pokeElemOff dst (j + 1) b1
+    pokeElemOff dst (j + 2) b2
+    pokeElemOff dst (j + 3) b3
+    pure $!
+         (acc .|. n0 .|. n1 .|. n2 .|. n3 .|. n4 .|. n5 .|. n6)
+         .&. 0x40 == 0
+         && slack == 0
+  _ -> pure False -- impossible: tail-length guard already rejected
diff --git a/lib/Data/ByteString/Base32/Internal.hs b/lib/Data/ByteString/Base32/Internal.hs
new file mode 100644
--- /dev/null
+++ b/lib/Data/ByteString/Base32/Internal.hs
@@ -0,0 +1,59 @@
+{-# OPTIONS_HADDOCK hide, prune #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module: Data.ByteString.Base32.Internal
+-- Copyright: (c) 2024 Jared Tobin
+-- License: MIT
+-- Maintainer: Jared Tobin <jared@ppad.tech>
+--
+-- Static rodata tables for the bech32 base32 charset, shared by
+-- 'Data.ByteString.Base32' and 'Data.ByteString.Bech32.Internal'.
+
+module Data.ByteString.Base32.Internal (
+    enc_tab
+  , dec_tab
+  ) where
+
+import qualified Data.ByteString as BS
+
+-- 32-byte encoding table: the bech32 character set.  Maps a 5-bit
+-- value (0..31) to its bech32 character.  ASCII-only with no embedded
+-- NUL, so the bytestring 'IsString' rule rewrites the literal to
+-- 'unsafePackAddress' and the bytes live in static rodata.
+enc_tab :: BS.ByteString
+enc_tab = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+{-# NOINLINE enc_tab #-}
+
+-- 256-byte reverse table.  Index by an ASCII byte to obtain its
+-- 5-bit value (biased into bit 5); valid bech32 chars map to
+-- 0x20..0x3f, every other byte maps to 0x40.
+--
+-- The encoding is chosen so the literal is strictly ASCII and
+-- contains no embedded NUL, which is what the bytestring 'IsString'
+-- rule needs to rewrite it into 'unsafePackAddress' (cf. 'enc_tab')
+-- - the bytes end up in static rodata, with no CAF allocation.
+--
+-- The 0x40 sentinel is distinguished by bit 6; no value 0x20..0x3f
+-- carries that bit, so callers OR-fold every lookup into an
+-- accumulator and test 'acc .&. 0x40 == 0' once at the end.  The
+-- 5-bit value is extracted as 'b .&. 0x1f'.
+dec_tab :: BS.ByteString
+dec_tab =
+  "\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x2f\x40\x2a\x31\x35\x34\x3a\x3e\x27\x25\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x3d\x40\x38\x2d\x39\x29\x28\x37\x40\x32\x36\x3f\x3b\x33\x40\
+  \\x21\x20\x23\x30\x2b\x3c\x2c\x2e\x26\x24\x22\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\
+  \\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40\x40"
+{-# NOINLINE dec_tab #-}
diff --git a/lib/Data/ByteString/Bech32.hs b/lib/Data/ByteString/Bech32.hs
--- a/lib/Data/ByteString/Bech32.hs
+++ b/lib/Data/ByteString/Bech32.hs
@@ -25,17 +25,9 @@
 import qualified Data.ByteString.Char8 as B8
 import qualified Data.ByteString.Base32 as B32
 import qualified Data.ByteString.Bech32.Internal as BI
-import qualified Data.ByteString.Builder as BSB
-import qualified Data.ByteString.Builder.Extra as BE
 import qualified Data.ByteString.Internal as BSI
 import qualified Data.Char as C (toLower, isLower, isAlpha)
 
--- realization for small builders
-toStrict :: BSB.Builder -> BS.ByteString
-toStrict = BS.toStrict
-  . BE.toLazyByteStringWith (BE.safeStrategy 128 BE.smallChunkSize) mempty
-{-# INLINE toStrict #-}
-
 create_checksum :: BS.ByteString -> BS.ByteString -> BS.ByteString
 create_checksum = BI.create_checksum BI.Bech32
 
@@ -52,11 +44,7 @@
   guard (BI.valid_hrp hrp)
   ws <- BI.as_word5 dat
   let check = create_checksum hrp ws
-      res = toStrict $
-           BSB.byteString hrp
-        <> BSB.word8 49 -- 1
-        <> BSB.byteString dat
-        <> BSB.byteString (BI.as_base32 check)
+      res = BS.concat [hrp, BS.singleton 49, dat, BI.as_base32 check]
   guard (BS.length res < 91)
   pure res
 
diff --git a/lib/Data/ByteString/Bech32/Internal.hs b/lib/Data/ByteString/Bech32/Internal.hs
--- a/lib/Data/ByteString/Bech32/Internal.hs
+++ b/lib/Data/ByteString/Bech32/Internal.hs
@@ -1,8 +1,6 @@
 {-# OPTIONS_HADDOCK hide, prune #-}
 {-# LANGUAGE BangPatterns #-}
-{-# LANGUAGE BinaryLiterals #-}
 {-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ViewPatterns #-}
 
 module Data.ByteString.Bech32.Internal (
@@ -14,80 +12,65 @@
   , valid_hrp
   ) where
 
-import Data.Bits ((.&.))
+import Data.Bits ((.&.), (.|.))
 import qualified Data.Bits as B
 import qualified Data.ByteString as BS
-import qualified Data.ByteString.Builder as BSB
-import qualified Data.ByteString.Builder.Extra as BE
+import Data.ByteString.Base32.Internal (enc_tab, dec_tab)
 import qualified Data.ByteString.Internal as BI
 import qualified Data.ByteString.Unsafe as BU
 import Data.Word (Word8, Word32)
+import Foreign.ForeignPtr (withForeignPtr)
+import Foreign.Ptr (Ptr, plusPtr)
+import Foreign.Storable (peekElemOff, pokeElemOff)
+import System.IO.Unsafe (unsafeDupablePerformIO)
 
 fi :: (Integral a, Num b) => a -> b
 fi = fromIntegral
 {-# INLINE fi #-}
 
--- realization for small builders
-toStrict :: BSB.Builder -> BS.ByteString
-toStrict = BS.toStrict
-  . BE.toLazyByteStringWith (BE.safeStrategy 128 BE.smallChunkSize) mempty
-{-# INLINE toStrict #-}
-
 _BECH32M_CONST :: Word32
 _BECH32M_CONST = 0x2bc830a3
 
-bech32_charset :: BS.ByteString
-bech32_charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
-
-word5 :: Word8 -> Maybe Word8
-word5 = \case
-  113 -> pure $! 00 -- 'q'
-  112 -> pure $! 01 -- 'p'
-  122 -> pure $! 02 -- 'z'
-  114 -> pure $! 03 -- 'r'
-  121 -> pure $! 04 -- 'y'
-  57  -> pure $! 05 -- '9'
-  120 -> pure $! 06 -- 'x'
-  56  -> pure $! 07 -- '8'
-  103 -> pure $! 08 -- 'g'
-  102 -> pure $! 09 -- 'f'
-  50  -> pure $! 10 -- '2'
-  116 -> pure $! 11 -- 't'
-  118 -> pure $! 12 -- 'v'
-  100 -> pure $! 13 -- 'd'
-  119 -> pure $! 14 -- 'w'
-  48  -> pure $! 15 -- '0'
-  115 -> pure $! 16 -- 's'
-  51  -> pure $! 17 -- '3'
-  106 -> pure $! 18 -- 'j'
-  110 -> pure $! 19 -- 'n'
-  53  -> pure $! 20 -- '5'
-  52  -> pure $! 21 -- '4'
-  107 -> pure $! 22 -- 'k'
-  104 -> pure $! 23 -- 'h'
-  99  -> pure $! 24 -- 'c'
-  101 -> pure $! 25 -- 'e'
-  54  -> pure $! 26 -- '6'
-  109 -> pure $! 27 -- 'm'
-  117 -> pure $! 28 -- 'u'
-  97  -> pure $! 29 -- 'a'
-  55  -> pure $! 30 -- '7'
-  108 -> pure $! 31 -- 'l'
-  _   -> Nothing
-{-# INLINE word5 #-}
-
--- base32 -> word5
+-- | Translate base32 bytestring to its 5-bit-value bytestring.  Each
+--   input byte is looked up in 'dec_tab'; if any byte is not a valid
+--   bech32 char, returns 'Nothing'.
 as_word5 :: BS.ByteString -> Maybe BS.ByteString
-as_word5 = go mempty where
-  go acc bs = case BS.uncons bs of
-    Nothing -> pure (toStrict acc)
-    Just (h, t) -> do
-      w5 <- word5 (fi h)
-      go (acc <> BSB.word8 w5) t
+as_word5 (BI.PS sfp soff l) = case dec_tab of
+  BI.PS tfp toff _ -> unsafeDupablePerformIO $ do
+    fp <- BI.mallocByteString l
+    ok <- withForeignPtr fp  $ \dst ->
+          withForeignPtr sfp $ \sp0 ->
+          withForeignPtr tfp $ \tp0 -> do
+            let !sp = sp0 `plusPtr` soff :: Ptr Word8
+                !tp = tp0 `plusPtr` toff :: Ptr Word8
+                loop !i !acc
+                  | i == l    = pure $! acc .&. 0x40 == 0
+                  | otherwise = do
+                      c <- peekElemOff sp i
+                      n <- peekElemOff tp (fi c)
+                      pokeElemOff dst i (n .&. 0x1f)
+                      loop (i + 1) (acc .|. n)
+            loop 0 0
+    pure $! if ok then Just (BI.PS fp 0 l) else Nothing
 
--- word5 -> base32
+-- | Translate a 5-bit-value bytestring to its bech32 base32
+--   bytestring.
 as_base32 :: BS.ByteString -> BS.ByteString
-as_base32 = BS.map (BU.unsafeIndex bech32_charset . fi)
+as_base32 (BI.PS sfp soff l) = case enc_tab of
+  BI.PS tfp toff _ ->
+    BI.unsafeCreate l $ \dst ->
+      withForeignPtr sfp $ \sp0 ->
+      withForeignPtr tfp $ \tp0 -> do
+        let !sp = sp0 `plusPtr` soff :: Ptr Word8
+            !tp = tp0 `plusPtr` toff :: Ptr Word8
+            loop !i
+              | i == l    = pure ()
+              | otherwise = do
+                  v <- peekElemOff sp i
+                  c <- peekElemOff tp (fi v)
+                  pokeElemOff dst i c
+                  loop (i + 1)
+        loop 0
 
 polymod :: BS.ByteString -> Word32
 polymod = BS.foldl' alg 1 where
@@ -117,29 +100,51 @@
   | l == 0 || l > 83 = False
   | otherwise = BS.all (\b -> (b > 32) && (b < 127)) hrp
 
+-- | Build the bech32 HRP expansion: high-5-bits of each HRP byte,
+--   then a single 0, then low-5-bits of each HRP byte.
 hrp_expand :: BS.ByteString -> BS.ByteString
-hrp_expand bs = toStrict
-  $  BSB.byteString (BS.map (`B.shiftR` 5) bs)
-  <> BSB.word8 0
-  <> BSB.byteString (BS.map (.&. 0b11111) bs)
+hrp_expand (BI.PS sfp soff l) =
+  BI.unsafeCreate (2 * l + 1) $ \dst ->
+    withForeignPtr sfp $ \sp0 -> do
+      let !sp = sp0 `plusPtr` soff :: Ptr Word8
+          loop_hi !i
+            | i == l    = pure ()
+            | otherwise = do
+                c <- peekElemOff sp i
+                pokeElemOff dst i (c `B.shiftR` 5)
+                loop_hi (i + 1)
+          loop_lo !i
+            | i == l    = pure ()
+            | otherwise = do
+                c <- peekElemOff sp i
+                pokeElemOff dst (l + 1 + i) (c .&. 0x1f)
+                loop_lo (i + 1)
+      loop_hi 0
+      pokeElemOff dst l (0 :: Word8)
+      loop_lo 0
 
 data Encoding =
     Bech32
   | Bech32m
 
-create_checksum :: Encoding -> BS.ByteString -> BS.ByteString -> BS.ByteString
+zero6 :: BS.ByteString
+zero6 = BS.replicate 6 0
+{-# NOINLINE zero6 #-}
+
+create_checksum
+  :: Encoding -> BS.ByteString -> BS.ByteString -> BS.ByteString
 create_checksum enc hrp dat =
-  let pre = hrp_expand hrp <> dat
-      pay = toStrict $
-           BSB.byteString pre
-        <> BSB.byteString "\NUL\NUL\NUL\NUL\NUL\NUL"
-      pm = polymod pay `B.xor` case enc of
+  let !pay = BS.concat [hrp_expand hrp, dat, zero6]
+      !pm  = polymod pay `B.xor` case enc of
         Bech32  -> 1
         Bech32m -> _BECH32M_CONST
-
-      code i = (fi (pm `B.shiftR` fi i) .&. 0b11111)
-
-  in  BS.map code "\EM\DC4\SI\n\ENQ\NUL" -- BS.pack [25, 20, 15, 10, 5, 0]
+  in  BI.unsafeCreate 6 $ \dst -> do
+        pokeElemOff dst 0 (fi (pm `B.shiftR` 25) .&. 0x1f :: Word8)
+        pokeElemOff dst 1 (fi (pm `B.shiftR` 20) .&. 0x1f :: Word8)
+        pokeElemOff dst 2 (fi (pm `B.shiftR` 15) .&. 0x1f :: Word8)
+        pokeElemOff dst 3 (fi (pm `B.shiftR` 10) .&. 0x1f :: Word8)
+        pokeElemOff dst 4 (fi (pm `B.shiftR`  5) .&. 0x1f :: Word8)
+        pokeElemOff dst 5 (fi  pm               .&. 0x1f :: Word8)
 
 verify :: Encoding -> BS.ByteString -> Bool
 verify enc b32 = case BS.elemIndexEnd 0x31 b32 of
@@ -154,4 +159,3 @@
             in  polymod bs == case enc of
                   Bech32 -> 1
                   Bech32m -> _BECH32M_CONST
-
diff --git a/lib/Data/ByteString/Bech32m.hs b/lib/Data/ByteString/Bech32m.hs
--- a/lib/Data/ByteString/Bech32m.hs
+++ b/lib/Data/ByteString/Bech32m.hs
@@ -26,17 +26,9 @@
 import qualified Data.ByteString.Char8 as B8
 import qualified Data.ByteString.Base32 as B32
 import qualified Data.ByteString.Bech32.Internal as BI
-import qualified Data.ByteString.Builder as BSB
-import qualified Data.ByteString.Builder.Extra as BE
 import qualified Data.ByteString.Internal as BSI
 import qualified Data.Char as C (toLower)
 
--- realization for small builders
-toStrict :: BSB.Builder -> BS.ByteString
-toStrict = BS.toStrict
-  . BE.toLazyByteStringWith (BE.safeStrategy 128 BE.smallChunkSize) mempty
-{-# INLINE toStrict #-}
-
 create_checksum :: BS.ByteString -> BS.ByteString -> BS.ByteString
 create_checksum = BI.create_checksum BI.Bech32m
 
@@ -53,11 +45,7 @@
   guard (BI.valid_hrp hrp)
   ws <- BI.as_word5 dat
   let check = create_checksum hrp ws
-      res = toStrict $
-           BSB.byteString hrp
-        <> BSB.word8 49 -- 1
-        <> BSB.byteString dat
-        <> BSB.byteString (BI.as_base32 check)
+      res = BS.concat [hrp, BS.singleton 49, dat, BI.as_base32 check]
   guard (BS.length res < 91)
   pure res
 
diff --git a/ppad-bech32.cabal b/ppad-bech32.cabal
--- a/ppad-bech32.cabal
+++ b/ppad-bech32.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               ppad-bech32
-version:            0.2.4
+version:            0.2.5
 synopsis:           bech32 and bech32m encoding/decoding, per BIPs 173 & 350.
 license:            MIT
 license-file:       LICENSE
@@ -35,6 +35,8 @@
     , Data.ByteString.Bech32.Internal
     , Data.ByteString.Bech32
     , Data.ByteString.Bech32m
+  other-modules:
+      Data.ByteString.Base32.Internal
   build-depends:
       base >= 4.9 && < 5
     , bytestring >= 0.9 && < 0.13
