diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,9 @@
 # Changelog and Acknowledgements
 
+## 2.3.1
+* Fixed [#52](https://github.com/sjshuck/hs-pcre2/issues/52) where the caret
+  appeared in the wrong place.
+
 ## 2.3.0
 * Disabled building JIT by default.
   * Removed various JIT-related functions and constants from
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -7,11 +7,14 @@
 
 ## Teasers
 ```haskell
-licensePlate :: Text -> Maybe Text
-licensePlate = match "[A-Z]{3}[0-9]{3,4}"
+licensePlateRegex :: (Alternative f) => Text -> f Text
+licensePlateRegex = match "[A-Z]{3}[0-9]{3,4}"
 
-licensePlates :: Text -> [Text]
-licensePlates = match "[A-Z]{3}[0-9]{3,4}"
+firstLicensePlate :: Text -> Maybe Text
+firstLicensePlate = licensePlateRegex
+
+allLicensePlates :: Text -> [Text]
+allLicensePlates = licensePlateRegex
 ```
 ```haskell
 case "The quick brown fox" of
@@ -31,16 +34,21 @@
         \s*$          # Ignore trailing whitespace
     |]
 
-forMOf kv'd file $ execStateT $ do
-    k <- gets $ capture @1
-    v <- gets $ capture @2
-    liftIO $ Text.putStrLn $ "found " <> k <> " set to " <> v
+forOf kv'd file $ \kv -> do
+    let k = capture @1 kv
+        v = capture @2 kv
 
-    case myMap ^. at k of
+    Text.putStrLn $ "found " <> k <> " set to " <> v
+
+    kv' <- case Map.lookup k replacements of
         Just v' | v /= v' -> do
-            liftIO $ Text.putStrLn $ "setting " <> k <> " to " <> v'
-            _capture @2 .= v'
-        _ -> liftIO $ Text.putStrLn $ "no change for " <> k
+            Text.putStrLn $ "setting " <> k <> " to " <> v'
+            return $ set (_capture @2) v' kv
+        _ -> do
+            Text.putStrLn $ "no change for " <> k
+            return kv
+
+    ...
 ```
 
 ## Features
@@ -49,7 +57,6 @@
   `-> result`.
 * No [custom typeclasses](https://hackage.haskell.org/package/regex-base/docs/Text-Regex-Base-RegexLike.html#t:RegexContext).
 * A single datatype for both compile and match options, the `Option` monoid.
-* UTF-8 `Text` everywhere.
 * Match success expressed via `Alternative`.
 * Opt-in Template Haskell facilities for compile-time verification of patterns,
   indexing captures, and memoizing inline regexes.
@@ -66,14 +73,9 @@
   complete, exposed Haskell binding.
 
 ## Performance
-Currently we are slower than other libraries.  The difference is less pronounced
-for `Text` where no conversions are necessary to use this library.
+Currently (2026-05-02) we are much slower than other libraries.
 
-| Operation                   | `pcre2`    | `regex-pcre-builtin` | `pcre-light` |
-| :--                         |        --: |                  --: |          --: |
-| Matching a `String`         | 1.22 &mu;s |           0.34 &mu;s |   0.23 &mu;s |
-| Matching a `Text` (shorter) | 1.22 &mu;s |           0.93 &mu;s |   0.29 &mu;s |
-| Matching a `Text` (longer)  | 1.89 &mu;s |           1.52 &mu;s |  11.12 &mu;s |
+![Benchmarks graph](https://raw.githubusercontent.com/sjshuck/hs-pcre2/master/bench/bench.svg)
 
 If it's really regex processing that's causing a bottleneck,
 [pcre-light](https://hackage.haskell.org/package/pcre-light)/[-heavy](https://hackage.haskell.org/package/pcre-heavy)/[lens-regex-pcre](https://hackage.haskell.org/package/lens-regex-pcre)
@@ -85,6 +87,7 @@
   some upstream changes as well but in theory it's possible.
 * Improve compile time.
   * Support external `libpcre2` maybe.
+* Support more types than just `Text`.
 
 ## License
 [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.html).
diff --git a/bench/Bench.hs b/bench/Bench.hs
--- a/bench/Bench.hs
+++ b/bench/Bench.hs
@@ -1,21 +1,20 @@
 {-# LANGUAGE DataKinds #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE ViewPatterns #-}
 
 module Main where
 
-import           Criterion.Main
-import           Data.List.NonEmpty          (NonEmpty(..))
-import qualified Data.Text                   as Text
-import           Lens.Micro.Platform
-import           System.IO.Unsafe            (unsafePerformIO)
-import qualified Text.Regex.PCRE
-import qualified Text.Regex.PCRE.Light.Char8
-import qualified Text.Regex.PCRE.String
-import qualified Text.Regex.PCRE.Text
-import           Text.Regex.Pcre2
+import Data.List.NonEmpty          (NonEmpty(..))
+import Data.Text                   qualified as Text
+import Lens.Micro.Platform
+import System.IO.Unsafe            (unsafePerformIO)
+import Test.Tasty.Bench
+import Text.Regex.PCRE             qualified
+import Text.Regex.PCRE.Light.Char8 qualified
+import Text.Regex.PCRE.String      qualified
+import Text.Regex.PCRE.Text        qualified
+import Text.Regex.Pcre2
 
 main :: IO ()
 main = defaultMain [
@@ -52,9 +51,9 @@
             in bar],
 
     bgroup "texts" [
-        bgroupTexts "short" strings,
+        bgroupTexts "shorter" strings,
 
-        bgroupTexts "long" (
+        bgroupTexts "longer" (
             "foo (ba*r) baz",
             "foo b" ++ replicate 1000 'a' ++ "r baz")],
 
diff --git a/pcre2.cabal b/pcre2.cabal
--- a/pcre2.cabal
+++ b/pcre2.cabal
@@ -1,11 +1,11 @@
 cabal-version: 1.12
 
--- This file has been generated from package.yaml by hpack version 0.38.1.
+-- This file has been generated from package.yaml by hpack version 0.39.1.
 --
 -- see: https://github.com/sol/hpack
 
 name:           pcre2
-version:        2.3.0
+version:        2.3.1
 synopsis:       Regular expressions via the PCRE2 C library (included)
 description:    Please see the README on GitHub at <https://github.com/sjshuck/hs-pcre2>
 category:       Text
@@ -203,7 +203,7 @@
     , mtl
     , template-haskell
     , text >=2
-  default-language: Haskell2010
+  default-language: GHC2021
   if os(darwin)
     ghc-options: -optc=-Wno-ignored-qualifiers
   else
@@ -231,7 +231,7 @@
     , tasty-hunit
     , template-haskell
     , text >=2
-  default-language: Haskell2010
+  default-language: GHC2021
 
 benchmark pcre2-benchmarks
   type: exitcode-stdio-1.0
@@ -240,17 +240,17 @@
       Paths_pcre2
   hs-source-dirs:
       bench
-  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  ghc-options: -rtsopts
   build-depends:
       base >=4.9 && <5
     , containers
-    , criterion
     , microlens
     , microlens-platform
     , mtl
     , pcre-light
     , pcre2
     , regex-pcre-builtin
+    , tasty-bench
     , template-haskell
     , text >=2
-  default-language: Haskell2010
+  default-language: GHC2021
diff --git a/src/hs/Text/Regex/Pcre2.hs b/src/hs/Text/Regex/Pcre2.hs
--- a/src/hs/Text/Regex/Pcre2.hs
+++ b/src/hs/Text/Regex/Pcre2.hs
@@ -290,8 +290,8 @@
     -- * User errors
     SomePcre2Exception(),
     Pcre2Exception(),
-    Pcre2CompileException())
-where
+    Pcre2CompileException(),
+) where
 
 import Text.Regex.Pcre2.Internal
 import Text.Regex.Pcre2.TH
diff --git a/src/hs/Text/Regex/Pcre2/Internal.hs b/src/hs/Text/Regex/Pcre2/Internal.hs
--- a/src/hs/Text/Regex/Pcre2/Internal.hs
+++ b/src/hs/Text/Regex/Pcre2/Internal.hs
@@ -1,35 +1,32 @@
-{-# LANGUAGE DeriveFunctor #-}
-{-# LANGUAGE ExistentialQuantification #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE MultiWayIf #-}
-{-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE RecordWildCards #-}
 
 module Text.Regex.Pcre2.Internal where
 
-import           Control.Applicative        (Alternative(..))
-import           Control.Exception
-import           Control.Monad
-import           Control.Monad.Cont
-import           Control.Monad.State.Strict
-import           Data.Either                (partitionEithers)
-import           Data.Foldable              (foldl', toList)
-import           Data.Functor.Identity      (Identity(..))
-import           Data.IORef
-import           Data.List.NonEmpty         (NonEmpty(..))
-import           Data.Maybe                 (fromMaybe)
-import           Data.Monoid                (Alt(..), First)
-import           Data.Proxy                 (Proxy(..))
-import           Data.Text                  (Text)
-import qualified Data.Text                  as Text
-import qualified Data.Text.Foreign          as Text
-import           Data.Typeable              (cast)
-import           Foreign
-import           Foreign.C.Types            (CInt(..), CUChar, CUInt(..))
-import           Lens.Micro
-import           Lens.Micro.Extras          (preview, view)
-import           System.IO.Unsafe           (unsafePerformIO)
-import           Text.Regex.Pcre2.Foreign
+import Control.Applicative        (Alternative(..))
+import Control.Exception
+import Control.Monad
+import Control.Monad.Cont
+import Control.Monad.State.Strict
+import Data.Either                (partitionEithers)
+import Data.Foldable              (foldl', toList)
+import Data.Functor.Identity      (Identity(..))
+import Data.IORef
+import Data.List.NonEmpty         (NonEmpty(..))
+import Data.Maybe                 (fromMaybe)
+import Data.Monoid                (Alt(..), First)
+import Data.Proxy                 (Proxy(..))
+import Data.Text                  (Text)
+import Data.Text                  qualified as Text
+import Data.Text.Foreign          qualified as Text
+import Data.Typeable              (cast)
+import Foreign
+import Foreign.C.Types            (CInt(..), CUChar, CUInt(..))
+import Lens.Micro
+import Lens.Micro.Extras          (preview, view)
+import System.IO.Unsafe           (unsafePerformIO)
+import Text.Regex.Pcre2.Foreign
 
 -- * General utilities
 
@@ -1156,7 +1153,8 @@
             -- FIXME Do not use Text.length since offsets are in code units
             | offset' == Text.length patt = pattLines
             | otherwise                   = insertCaretLine numberedPattLines
-        offset' = fromIntegral offset
+        -- See https://github.com/PCRE2Project/pcre2/issues/906
+        offset' = max (fromIntegral offset - 1) 0
         pattLines = unchompedLines $ Text.unpack patt
         numberedPattLines = zip [0 :: Int ..] pattLines
         (caretRow, caretCol) = (!! offset') $ do
diff --git a/src/hs/Text/Regex/Pcre2/TH.hs b/src/hs/Text/Regex/Pcre2/TH.hs
--- a/src/hs/Text/Regex/Pcre2/TH.hs
+++ b/src/hs/Text/Regex/Pcre2/TH.hs
@@ -1,43 +1,38 @@
 {-# LANGUAGE AllowAmbiguousTypes #-}
 {-# LANGUAGE DataKinds #-}
 {-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE PolyKinds #-}
-{-# LANGUAGE RankNTypes #-}
-{-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE TypeOperators #-}
 {-# LANGUAGE UndecidableInstances #-}
 
 module Text.Regex.Pcre2.TH where
 
-import           Control.Applicative        (Alternative(..))
-import           Control.Monad              (forM)
-import           Control.Monad.State.Strict (evalStateT)
-import           Data.IORef
-import           Data.List                  (sortBy)
-import           Data.List.NonEmpty         (NonEmpty)
-import           Data.Map.Lazy              (Map)
-import qualified Data.Map.Lazy              as Map
-import           Data.Ord                   (comparing)
-import           Data.Proxy                 (Proxy(..))
-import           Data.Text                  (Text)
-import qualified Data.Text                  as Text
-import qualified Data.Text.Foreign          as Text
-import           Data.Type.Bool             (If)
-import           Foreign
-import           Foreign.C                  (CUChar, CUInt)
-import           GHC.TypeLits               hiding (Text)
-import qualified GHC.TypeLits               as TypeLits
-import           Language.Haskell.TH
-import           Language.Haskell.TH.Quote
-import           Language.Haskell.TH.Syntax (liftData)
-import           Lens.Micro
-import           Lens.Micro.Extras          (view)
-import           System.IO.Unsafe           (unsafePerformIO)
-import           Text.Regex.Pcre2.Foreign
-import           Text.Regex.Pcre2.Internal
+import Control.Applicative        (Alternative(..))
+import Control.Monad              (forM)
+import Control.Monad.State.Strict (evalStateT)
+import Data.IORef
+import Data.List                  (sortBy)
+import Data.List.NonEmpty         (NonEmpty)
+import Data.Map.Lazy              (Map)
+import Data.Map.Lazy              qualified as Map
+import Data.Ord                   (comparing)
+import Data.Proxy                 (Proxy(..))
+import Data.Text                  (Text)
+import Data.Text                  qualified as Text
+import Data.Text.Foreign          qualified as Text
+import Data.Type.Bool             (If)
+import Foreign
+import Foreign.C                  (CUChar, CUInt)
+import GHC.TypeLits               hiding (Text)
+import GHC.TypeLits               qualified as TypeLits
+import Language.Haskell.TH
+import Language.Haskell.TH.Quote  (QuasiQuoter(..))
+import Language.Haskell.TH.Syntax (liftData)
+import Lens.Micro
+import Lens.Micro.Extras          (view)
+import System.IO.Unsafe           (unsafePerformIO)
+import Text.Regex.Pcre2.Foreign
+import Text.Regex.Pcre2.Internal
 
 -- | A wrapper around a list of captures that carries additional type-level
 -- information about the number and names of those captures.
@@ -59,7 +54,8 @@
 
 -- | Look up the number of a capture at compile time, either by number or by
 -- name.  Throw a helpful 'TypeError' if the index doesn't exist.
-type family CaptNum (i :: k) (info :: CapturesInfo) :: Nat where
+type CaptNum :: k -> CapturesInfo -> Nat
+type family CaptNum i info where
     CaptNum (i :: Nat) '(hi, _) = If (i <=? hi)
         {- then -} i
         {- else -} (TypeError
diff --git a/src/hs/Text/Regex/Pcre2/Unsafe.hs b/src/hs/Text/Regex/Pcre2/Unsafe.hs
--- a/src/hs/Text/Regex/Pcre2/Unsafe.hs
+++ b/src/hs/Text/Regex/Pcre2/Unsafe.hs
@@ -28,8 +28,8 @@
     CalloutIndex(..),
     CalloutResult(..),
     SubCalloutInfo(..),
-    SubCalloutResult(..))
-where
+    SubCalloutResult(..),
+) where
 
 import Text.Regex.Pcre2.Internal
 import Text.Regex.Pcre2.TH
diff --git a/test/Spec.hs b/test/Spec.hs
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -2,26 +2,24 @@
 {-# LANGUAGE DataKinds #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE ViewPatterns #-}
 
 module Main where
 
-import           Control.Applicative     (Alternative)
-import           Control.Exception
-import           Control.Monad           (forM_, void)
-import           Control.Monad.RWS.Lazy  (RWS, ask, evalRWS, tell)
-import           Data.IORef              (modifyIORef', newIORef, readIORef)
-import           Data.List.NonEmpty      (NonEmpty(..))
-import           Data.Text               (Text)
-import qualified Data.Text               as Text
-import           Lens.Micro.Platform
-import           Test.Tasty              (defaultMain, testGroup)
-import           Test.Tasty.HUnit
-import           Text.Printf             (printf)
-import           Text.Regex.Pcre2
-import           Text.Regex.Pcre2.Unsafe
+import Control.Applicative     (Alternative)
+import Control.Exception
+import Control.Monad           (forM_, unless, void)
+import Control.Monad.RWS.Lazy  (RWS, ask, evalRWS, tell)
+import Data.IORef              (modifyIORef', newIORef, readIORef)
+import Data.List.NonEmpty      (NonEmpty(..))
+import Data.Text               (Text)
+import Data.Text               qualified as Text
+import Lens.Micro.Platform
+import Test.Tasty              (defaultMain, testGroup)
+import Test.Tasty.HUnit
+import Text.Printf             (printf)
+import Text.Regex.Pcre2
+import Text.Regex.Pcre2.Unsafe
 
 main :: IO ()
 main = defaultMain $ testGroup "tests" [
@@ -139,7 +137,20 @@
         testCase "are catchable using instance Alternative IO" $ do
             let example = handle @SomePcre2Exception (\_ -> return "broken") $
                     broken "foo"
-            example >>= (@?= "broken")],
+            example >>= (@?= "broken"),
+
+        testCase ("show the caret in the right place" `issue` 52) $ do
+            infoOrE <- try @Pcre2CompileException $
+                predictCapturesInfo mempty "abc)def"
+            msg <- case infoOrE of
+                Right _ -> assertFailure "regex compiled"
+                Left e  -> return $ displayException e
+            case lines msg of
+                [_, pattLine, caretLine] ->
+                    assertBool ("caret doesn't line up:\n" ++ msg) $
+                        (')', '^') `elem` zip pattLine caretLine
+                _ -> assertFailure $
+                    "didn't get 3-line message, got:\n" ++ msg],
 
     testGroup "native substitution" [
         testCase "works using sub" $ do
