diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## v1.2.1
+
+* Improve error messages to include the source line where the error occurred
+
 ## v1.2.0
 
 * Add `Exception` instance to `DecodeError`
diff --git a/kdl-hs.cabal b/kdl-hs.cabal
--- a/kdl-hs.cabal
+++ b/kdl-hs.cabal
@@ -1,7 +1,7 @@
 cabal-version: 3.0
 
 name: kdl-hs
-version: 1.2.0
+version: 1.2.1
 synopsis: KDL language parser and API
 description: KDL language parser and API.
 homepage: https://github.com/brandonchinn178/kdl-hs#readme
@@ -45,6 +45,7 @@
       base < 5
     , containers
     , data-default
+    , filepath
     , megaparsec
     , prettyprinter >= 1.7.0
     , scientific
@@ -97,7 +98,7 @@
     , pretty-show
     , process
     , scientific
-    , skeletest
+    , skeletest >= 0.4
     , temporary
     , text
   default-language: GHC2021
diff --git a/src/KDL/Decoder/Arrow.hs b/src/KDL/Decoder/Arrow.hs
--- a/src/KDL/Decoder/Arrow.hs
+++ b/src/KDL/Decoder/Arrow.hs
@@ -103,10 +103,12 @@
  )
 import Control.Monad (unless, when)
 import Control.Monad.Trans.Class qualified as Trans
+import Control.Monad.Trans.Except qualified as ExceptT
 import Control.Monad.Trans.State.Strict (StateT)
 import Control.Monad.Trans.State.Strict qualified as StateT
 import Data.Bifunctor (first)
 import Data.Bits (finiteBitSize)
+import Data.Functor.Identity (runIdentity)
 import Data.Int (Int64)
 import Data.List (partition)
 import Data.List.NonEmpty qualified as NonEmpty
@@ -119,6 +121,7 @@
 import Data.Set qualified as Set
 import Data.Text (Text)
 import Data.Text qualified as Text
+import Data.Text.IO qualified as Text
 import Data.Typeable (Typeable, typeRep)
 import Data.Word (Word16, Word32, Word64, Word8)
 import GHC.Int (Int16, Int32, Int8)
@@ -133,29 +136,23 @@
   TypedValueSchema (..),
   getValueSchemaNames,
  )
-import KDL.Parser (parse, parseFile)
-import KDL.Types (
-  Ann (..),
-  Document,
-  Entry (..),
-  Identifier (..),
-  Node (..),
-  NodeList (..),
-  Value (..),
-  ValueData (..),
-  def,
- )
+import KDL.Parser (ParseConfig (..), parse, parseFile, parseWith)
+import KDL.Types (Ann (..), Document, Entry (..), Identifier (..), IdentifierExtension (..), Node (..), NodeExtension (..), NodeList (..), Span (..), Value (..), ValueData (..), ValueExtension (..), def)
 import Numeric.Natural (Natural)
-import Prelude hiding (any, fail, null)
+import Prelude hiding (any, fail, null, span)
 import Prelude qualified
 
 -- | Decode the given KDL configuration with the given decoder.
 decodeWith :: DocumentDecoder a -> Text -> Either DecodeError a
-decodeWith decoder = decodeFromParseResult decoder Nothing . parse
+decodeWith decoder input = runIdentity $ do
+  let doc = parse input
+  decodeFromParseResult Nothing (pure input) decoder doc
 
 -- | Read KDL configuration from the given file path and decode it with the given decoder.
 decodeFileWith :: DocumentDecoder a -> FilePath -> IO (Either DecodeError a)
-decodeFileWith decoder fp = decodeFromParseResult decoder (Just fp) <$> parseFile fp
+decodeFileWith decoder fp = do
+  doc <- parseFile fp
+  decodeFromParseResult (Just fp) (Text.readFile fp) decoder doc
 
 -- | Decode an already-parsed 'Document' with the given decoder.
 decodeDocWith :: DocumentDecoder a -> Document -> Either DecodeError a
@@ -164,15 +161,49 @@
     decoder.run ()
 
 decodeFromParseResult ::
-  DocumentDecoder a ->
+  (Monad m) =>
   Maybe FilePath ->
+  m Text ->
+  DocumentDecoder a ->
   Either Text Document ->
-  Either DecodeError a
-decodeFromParseResult decoder mPath =
-  first (\e -> e{filepath = mPath}) . \case
+  m (Either DecodeError a)
+decodeFromParseResult mPath getInput decoder =
+  firstM augmentError . \case
     Left e -> runDecodeM . decodeThrow $ DecodeError_ParseError e
     Right doc -> decodeDocWith decoder doc
+ where
+  augmentError originalError = runEarlyReturn $ do
+    input <- Trans.lift getInput
+    doc <-
+      case parseWith def{includeSpans = True} input of
+        Right doc -> pure doc
+        -- should not happen; return the plain error if it happens
+        Left _ -> returnE originalError
+    err <-
+      case decodeDocWith decoder doc of
+        Left err -> pure err
+        -- should not happen; return the plain error if it happens
+        Right _ -> returnE originalError
+    pure
+      DecodeError
+        { filepath = mPath
+        , errors = fmap (addSrcLine input) err.errors
+        }
 
+  addSrcLine input =
+    let inputLines = Text.lines input
+        addSrcLine' ctx span = ctx{srcLine = safeIndex (span.startLine - 1) inputLines}
+     in first (\ctx -> maybe ctx (addSrcLine' ctx) ctx.span)
+
+  runEarlyReturn m = ExceptT.runExceptT m >>= either pure pure
+  returnE = ExceptT.throwE
+
+  firstM f = either (fmap Left . f) (pure . Right)
+  safeIndex (n :: Int) = \case
+    _ | n < 0 -> Nothing
+    [] -> Nothing
+    x : xs -> if n == 0 then Just x else safeIndex (n - 1) xs
+
 {----- Decoder -----}
 
 {----- Decoding Document -----}
@@ -291,11 +322,25 @@
       index <- StateT.gets (getNodeIndex name.value)
       StateT.modify $ \s -> s{object = s.object{nodes = nodes'}}
       b <-
-        Trans.lift . addContext ContextNode{name, index} $
+        Trans.lift . addContext (nodeSpan node_) ContextNode{name, index} $
           decodeNode node_
       StateT.modify $ \s -> s{history = s.history{nodesSeen = inc name.value s.history.nodesSeen}}
       pure $ Just (node_, b)
  where
+  -- start of Node span -> end of last Entry span
+  nodeSpan (node_ :: Node) =
+    let startSpan = node_.ext.span
+        endSpan =
+          case NonEmpty.nonEmpty node_.entries of
+            Just entries -> (NonEmpty.last entries).value.ext.span
+            Nothing -> node_.name.ext.span
+     in Span
+          { startLine = startSpan.startLine
+          , startCol = startSpan.startCol
+          , endLine = endSpan.endLine
+          , endCol = endSpan.endCol
+          }
+
   inc k = Map.insertWith (+) k 1
 
 -- | Decode all remaining nodes.
@@ -695,7 +740,7 @@
       StateT.modify $ \s -> s{object = s.object{entries = entries'}}
 
       b <-
-        Trans.lift . addContext argContext $
+        Trans.lift . addContext entry.value.ext.span argContext $
           decodeValue a entry.value
       StateT.modify $ \s -> s{history = s.history{argsSeen = s.history.argsSeen + 1}}
       pure b
@@ -762,7 +807,7 @@
     Just (name, prop_, entries') -> do
       StateT.modify $ \s -> s{object = s.object{entries = entries'}}
       b <-
-        Trans.lift . addContext ContextProp{name} $
+        Trans.lift . addContext prop_.value.ext.span ContextProp{name} $
           decodeValue prop_.value
       StateT.modify $ \s -> s{history = s.history{propsSeen = Set.insert name s.history.propsSeen}}
       pure $ Just (name, b)
@@ -1080,9 +1125,14 @@
  where
   addLabel (ctx, kind) =
     let ctx' =
-          flip map ctx $ \case
-            ContextArg{label = _, ..} -> ContextArg{label = Just name, ..}
-            item -> item
+          ctx
+            { path =
+                [ case item of
+                    ContextArg{label = _, ..} -> ContextArg{label = Just name, ..}
+                    _ -> item
+                | item <- ctx.path
+                ]
+            }
         kind' =
           case kind of
             DecodeError_ExpectedArg{label = _, ..} -> DecodeError_ExpectedArg{label = Just name, ..}
diff --git a/src/KDL/Decoder/Internal/DecodeM.hs b/src/KDL/Decoder/Internal/DecodeM.hs
--- a/src/KDL/Decoder/Internal/DecodeM.hs
+++ b/src/KDL/Decoder/Internal/DecodeM.hs
@@ -22,10 +22,14 @@
 
 import Control.Applicative (Alternative (..))
 import Data.Bifunctor (first)
+import Data.Default (def)
 import Data.List.NonEmpty (NonEmpty (..))
 import Data.List.NonEmpty qualified as NonEmpty
 import Data.Text (Text)
 import KDL.Decoder.Internal.Error
+import KDL.Decoder.Internal.Error qualified as Error
+import KDL.Types (Span)
+import Prelude hiding (span)
 
 -- | The monad that returns either a 'DecodeError' or a result of type @a@.
 data DecodeM a
@@ -91,7 +95,7 @@
     EQ -> es1 <> es2
     GT -> es1
  where
-  key = length . fst . NonEmpty.head
+  key = length . (.path) . fst . NonEmpty.head
 
 mergeHintsL ::
   DecodeHints ->
@@ -112,17 +116,29 @@
   DecodeM_Found a es -> DecodeM_Found a (map f es)
   DecodeM_Fail es -> DecodeM_Fail (fmap f es)
 
+mapErrorContext :: (Error.Context -> Error.Context) -> DecodeM a -> DecodeM a
+mapErrorContext f = mapErrors (first f)
+
 -- | Throw an error.
 decodeThrow :: DecodeErrorKind -> DecodeM a
-decodeThrow e = DecodeM_Fail . NonEmpty.singleton $ ([], e)
+decodeThrow e = DecodeM_Fail . NonEmpty.singleton $ (def, e)
 
 -- | Throw a 'DecodeError_Custom' error.
 failM :: Text -> DecodeM a
 failM = decodeThrow . DecodeError_Custom
 
 -- | Add context to all errors that occur in the given action.
-addContext :: ContextItem -> DecodeM a -> DecodeM a
-addContext ctxItem = mapErrors (first (ctxItem :))
+addContext :: Span -> ContextItem -> DecodeM a -> DecodeM a
+addContext span ctxItem = mapErrorContext $ \ctx ->
+  ctx
+    { path = ctxItem : ctx.path
+    , -- Span should only be attached to the nearest context; i.e. the first
+      -- addContext that runs
+      span = ctx.span <|> span'
+    }
+ where
+  -- Ignore span if it's empty
+  span' = if span == def then Nothing else Just span
 
 -- | Discard hints after validating that an error context is successful.
 discardHints :: DecodeM a -> DecodeM a
diff --git a/src/KDL/Decoder/Internal/Error.hs b/src/KDL/Decoder/Internal/Error.hs
--- a/src/KDL/Decoder/Internal/Error.hs
+++ b/src/KDL/Decoder/Internal/Error.hs
@@ -9,15 +9,15 @@
   DecodeError (..),
   BaseDecodeError,
   DecodeErrorKind (..),
-  Context,
+  Context (..),
   ContextItem (..),
   renderDecodeError,
 ) where
 
 import Control.Exception (Exception (..))
+import Data.Default (Default (..))
 import Data.List.NonEmpty (NonEmpty)
 import Data.List.NonEmpty qualified as NonEmpty
-import Data.Map qualified as Map
 import Data.Text (Text)
 import Data.Text qualified as Text
 import KDL.Render (
@@ -26,8 +26,11 @@
  )
 import KDL.Types (
   Identifier,
+  Span (..),
   Value,
  )
+import System.FilePath (takeFileName)
+import Prelude hiding (span)
 
 data DecodeError = DecodeError
   { filepath :: Maybe FilePath
@@ -39,8 +42,22 @@
   displayException = Text.unpack . renderDecodeError
 
 type BaseDecodeError = (Context, DecodeErrorKind)
-type Context = [ContextItem]
 
+data Context = Context
+  { path :: [ContextItem]
+  , span :: Maybe Span
+  , srcLine :: Maybe Text
+  }
+  deriving (Show, Eq)
+
+instance Default Context where
+  def =
+    Context
+      { path = []
+      , span = Nothing
+      , srcLine = Nothing
+      }
+
 data ContextItem
   = ContextNode
       { name :: Identifier
@@ -72,35 +89,79 @@
 renderDecodeError decodeError =
   Text.intercalate "\n"
     . concatMap renderCtxErrors
-    . groupCtxErrors
+    . NonEmpty.groupAllWith1 groupKey
     $ decodeError.errors
  where
   -- Group errors with the same contexts together
-  groupCtxErrors es =
-    Map.toAscList . Map.fromListWith (<>) $
-      [ (ctx, [e])
-      | (ctx, e) <- NonEmpty.toList es
-      ]
-
-  addPath =
-    case decodeError.filepath of
-      Nothing -> id
-      Just fp -> let msg = "Failed to decode " <> Text.pack fp <> ":" in (msg :)
+  groupKey (ctx, _) = maybe (Left ctx.path) Right ctx.span
 
   renderCtxErrors = \case
     -- Special case parse errors, which shouldn't have a context
-    (_, [DecodeError_ParseError msg]) -> [msg]
-    (ctx, errs) -> addPath $ ("At: " <> renderCtxItems ctx) : renderErrors errs
+    (_, DecodeError_ParseError msg) NonEmpty.:| _ -> [msg]
+    errs ->
+      let (ctx, _) = NonEmpty.head errs
+       in renderCtx ctx $ (map (renderError . snd) $ NonEmpty.toList errs)
 
-  renderCtxItems items
-    | null items = "<root>"
-    | otherwise = Text.intercalate " > " . map renderCtxItem $ items
-  renderCtxItem = \case
+  renderCtx (ctx :: Context) =
+    case ctx.span of
+      Nothing -> renderCtxPath ctx.path
+      Just span -> renderCtxFull span ctx
+
+  -- If we don't have the error span, the best we can do is render the context path:
+  --
+  -- At: foo.kdl > user #0 > arg #0
+  -- ├─ error message
+  -- └─ another error message
+  renderCtxPath path errors =
+    let pathDisplay =
+          Text.intercalate " > " . concat $
+            [ case decodeError.filepath of
+                Nothing -> []
+                Just fp -> [Text.pack $ takeFileName fp]
+            , if null path then ["(root)"] else map renderCtxPathItem path
+            ]
+        errors' =
+          [ (if isLast then "└─ " else "├─ ") <> err
+          | (err, isLast) <- withIsLast errors
+          ]
+     in ("At: " <> pathDisplay) : errors'
+  renderCtxPathItem = \case
     ContextNode{..} -> renderIdentifier name <> " #" <> showT index
     ContextArg{..} -> renderArg index label
     ContextProp{..} -> "prop " <> renderIdentifier name
 
-  renderErrors = map ("  " <>) . concatMap (Text.lines . renderError)
+  -- If we have the error span, show a descriptive error message:
+  --
+  -- foo.kdl:3:16:
+  --     • Expected number, got string
+  --   |
+  -- 3 |     some_child bad-value
+  --   |                ^^^^^^^^^
+  renderCtxFull (span :: Span) ctx errors =
+    let spanDisplay =
+          Text.concat . map (<> ":") $
+            [ maybe "<input>" Text.pack decodeError.filepath
+            , showT span.startLine
+            , showT span.startCol
+            ]
+        errors' = map ("    • " <>) errors
+        preview =
+          case ctx.srcLine of
+            Nothing -> []
+            Just line ->
+              let lineNum = showT span.startLine
+                  spaces n = Text.replicate n " "
+                  renderPrefix isSpace = (if isSpace then spaces (Text.length lineNum) else lineNum) <> " │"
+                  spanLength =
+                    if span.startLine == span.endLine
+                      then span.endCol - span.startCol + 1
+                      else Text.length line - span.startCol + 1
+               in [ renderPrefix True
+                  , renderPrefix False <> " " <> line
+                  , renderPrefix True <> spaces span.startCol <> Text.replicate spanLength "^"
+                  ]
+     in spanDisplay : errors' ++ preview
+
   renderError = \case
     DecodeError_Custom msg -> msg
     DecodeError_ParseError msg -> msg
@@ -134,10 +195,13 @@
     [x] -> x
     [x, y] -> Text.unwords [x, conj, y]
     xs -> Text.intercalate ", " $ mapLast ((conj <> " ") <>) xs
+
   mapLast f = \case
     [] -> []
     [x] -> [f x]
     x : xs -> x : mapLast f xs
+
+  withIsLast = mapLast (True <$) . map (\x -> (x, False))
 
   -- Replace with Text.show after requiring at least text-2.1.2
   showT :: (Show a) => a -> Text
diff --git a/test/KDL/Decoder/SharedSpec/Template.hs b/test/KDL/Decoder/SharedSpec/Template.hs
--- a/test/KDL/Decoder/SharedSpec/Template.hs
+++ b/test/KDL/Decoder/SharedSpec/Template.hs
@@ -117,8 +117,8 @@
               _RETURN_((foo1, foo2))
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: <root>"
-            , "  Expected another node: foo"
+            [ "At: (root)"
+            , "└─ Expected another node: foo"
             ]
 
     -- Most behaviors tested with `node`
@@ -141,8 +141,11 @@
               _STMT_(KDL.nodeWith "foo" decodeFoo)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected string, got: 1.0"
+            [ "<input>:1:5:"
+            , "    • Expected string, got: 1.0"
+            , "  │"
+            , "1 │ foo 1.0"
+            , "  │     ^^^"
             ]
 
     -- Most behaviors tested with `nodeWith`
@@ -177,8 +180,11 @@
               _STMT_(_APOS_(KDL.nodeWith) "foo" ["FOO"] $ KDL.arg @Int)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Expected annotation to be one of [\"FOO\"], got: test"
+            [ "<input>:1:1:"
+            , "    • Expected annotation to be one of [\"FOO\"], got: test"
+            , "  │"
+            , "1 │ (test)foo 2"
+            , "  │ ^^^^^^^^^^^"
             ]
 
     describe "remainingNodes" $ do
@@ -240,8 +246,11 @@
               _STMT_(KDL.remainingNodesWith decodeNode)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: bar #1 > arg #0"
-            , "  Expected number, got: hello"
+            [ "<input>:1:19:"
+            , "    • Expected number, got: hello"
+            , "  │"
+            , "1 │ foo 1; bar 1; bar hello"
+            , "  │                   ^^^^^"
             ]
 
     -- Most behaviors tested with `remainingNodesWith`
@@ -278,8 +287,11 @@
               _STMT_(_APOS_(KDL.remainingNodesWith) ["FOO"] $ KDL.arg @Int)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #1"
-            , "  Expected annotation to be one of [\"FOO\"], got: test"
+            [ "<input>:1:13:"
+            , "    • Expected annotation to be one of [\"FOO\"], got: test"
+            , "  │"
+            , "1 │ (FOO)foo 1; (test)foo 2"
+            , "  │             ^^^^^^^^^^^"
             ]
 
     describe "argAt" $ do
@@ -297,8 +309,8 @@
               _STMT_(KDL.argAt @Int "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: <root>"
-            , "  Expected node: foo"
+            [ "At: (root)"
+            , "└─ Expected node: foo"
             ]
 
       it "fails if node has no args" $ do
@@ -307,8 +319,11 @@
               _STMT_(KDL.argAt @Int "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Expected arg #0 with type: number"
+            [ "<input>:1:1:"
+            , "    • Expected arg #0 with type: number"
+            , "  │"
+            , "1 │ foo"
+            , "  │ ^^^"
             ]
 
       it "fails if arg fails to parse" $ do
@@ -317,8 +332,11 @@
               _STMT_(KDL.argAt @Text "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected string, got: 1"
+            [ "<input>:1:5:"
+            , "    • Expected string, got: 1"
+            , "  │"
+            , "1 │ foo 1"
+            , "  │     ^"
             ]
 
       it "shows label on missing arg" $ do
@@ -327,8 +345,11 @@
               _STMT_(KDL.label "value" $ KDL.argAt @Int "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Expected arg 'value' with type: number"
+            [ "<input>:1:1:"
+            , "    • Expected arg 'value' with type: number"
+            , "  │"
+            , "1 │ foo"
+            , "  │ ^^^"
             ]
 
       it "shows label on invalid arg" $ do
@@ -337,8 +358,11 @@
               _STMT_(KDL.label "value" $ KDL.argAt @Text "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg 'value'"
-            , "  Expected string, got: 1"
+            [ "<input>:1:5:"
+            , "    • Expected string, got: 1"
+            , "  │"
+            , "1 │ foo 1"
+            , "  │     ^"
             ]
 
     -- Most behaviors tested with `argAt`
@@ -381,8 +405,11 @@
               _STMT_(_APOS_(KDL.argAtWith) "foo" ["VAL"] KDL.string)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            [ "<input>:1:5:"
+            , "    • Expected annotation to be one of [\"VAL\"], got: test"
+            , "  │"
+            , "1 │ foo (test)a"
+            , "  │     ^^^^^^^"
             ]
 
     describe "argsAt" $ do
@@ -410,8 +437,11 @@
               _STMT_(KDL.argsAt @Int "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #1"
-            , "  Expected number, got: asdf"
+            [ "<input>:1:7:"
+            , "    • Expected number, got: asdf"
+            , "  │"
+            , "1 │ foo 1 asdf"
+            , "  │       ^^^^"
             ]
 
     -- Most behaviors tested with `argsAt`
@@ -454,8 +484,11 @@
               _STMT_(_APOS_(KDL.argsAtWith) "foo" ["VAL"] KDL.string)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #1"
-            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            [ "<input>:1:12:"
+            , "    • Expected annotation to be one of [\"VAL\"], got: test"
+            , "  │"
+            , "1 │ foo (VAL)a (test)b"
+            , "  │            ^^^^^^^"
             ]
 
     describe "dashChildrenAt" $ do
@@ -483,8 +516,11 @@
               _STMT_(KDL.dashChildrenAt @Int "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > - #0"
-            , "  Unexpected arg #1: 2"
+            [ "<input>:1:7:"
+            , "    • Unexpected arg #1: 2"
+            , "  │"
+            , "1 │ foo { - 1 2; - 3 4; }"
+            , "  │       ^^^^^"
             ]
 
       it "fails if node has non-dash children" $ do
@@ -493,9 +529,12 @@
               _STMT_(KDL.dashChildrenAt @Int "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Unexpected node: bar #0"
-            , "  Expected another node: -"
+            [ "<input>:1:1:"
+            , "    • Expected another node: -"
+            , "    • Unexpected node: bar #0"
+            , "  │"
+            , "1 │ foo { - 1; bar 1 2 3; }"
+            , "  │ ^^^"
             ]
 
       it "fails if any child fails to parse" $ do
@@ -504,8 +543,11 @@
               _STMT_(KDL.dashChildrenAt @Int "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > - #1 > arg #0"
-            , "  Expected number, got: asdf"
+            [ "<input>:1:14:"
+            , "    • Expected number, got: asdf"
+            , "  │"
+            , "1 │ foo { - 1; - asdf; }"
+            , "  │              ^^^^"
             ]
 
     -- Most behaviors tested with `dashChildrenAt`
@@ -548,8 +590,11 @@
               _STMT_(_APOS_(KDL.dashChildrenAtWith) "foo" ["VAL"] KDL.string)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > - #0 > arg #0"
-            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            [ "<input>:1:9:"
+            , "    • Expected annotation to be one of [\"VAL\"], got: test"
+            , "  │"
+            , "1 │ foo { - (test)a; }"
+            , "  │         ^^^^^^^"
             ]
 
     describe "dashNodesAt" $ do
@@ -589,9 +634,12 @@
               _STMT_(KDL.dashNodesAt @Node "foo")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Unexpected node: bar #0"
-            , "  Expected another node: -"
+            [ "<input>:1:1:"
+            , "    • Expected another node: -"
+            , "    • Unexpected node: bar #0"
+            , "  │"
+            , "1 │ foo { - 1; bar 1 2 3; }"
+            , "  │ ^^^"
             ]
 
     -- Most behaviors tested with `dashNodesAt`
@@ -612,8 +660,11 @@
               _STMT_(KDL.dashNodesAtWith "foo" $ KDL.children $ KDL.argAt @Int "bar")
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > - #1 > bar #0 > arg #0"
-            , "  Expected number, got: test"
+            [ "<input>:1:29:"
+            , "    • Expected number, got: test"
+            , "  │"
+            , "1 │ foo { - { bar 1; }; - { bar test; }; }"
+            , "  │                             ^^^^"
             ]
 
   describe "NodeDecoder" $ do
@@ -645,8 +696,11 @@
               _STMT_(KDL.arg @Int)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Expected arg #0 with type: number"
+            [ "<input>:1:1:"
+            , "    • Expected arg #0 with type: number"
+            , "  │"
+            , "1 │ foo"
+            , "  │ ^^^"
             ]
 
       it "fails if argument fails to parse" $ do
@@ -655,8 +709,11 @@
               _STMT_(KDL.arg @Int)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected number, got: test"
+            [ "<input>:1:5:"
+            , "    • Expected number, got: test"
+            , "  │"
+            , "1 │ foo test"
+            , "  │     ^^^^"
             ]
 
       it "fails if not all arguments are decoded" $ do
@@ -665,8 +722,11 @@
               _STMT_(KDL.arg @Int)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Unexpected arg #1: 2"
+            [ "<input>:1:1:"
+            , "    • Unexpected arg #1: 2"
+            , "  │"
+            , "1 │ foo 1 2 3"
+            , "  │ ^^^^^^^^^"
             ]
 
       it "shows label on missing arg" $ do
@@ -675,8 +735,11 @@
               _STMT_(KDL.label "value" $ KDL.arg @Int)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Expected arg 'value' with type: number"
+            [ "<input>:1:1:"
+            , "    • Expected arg 'value' with type: number"
+            , "  │"
+            , "1 │ foo"
+            , "  │ ^^^"
             ]
 
       it "shows expected types on missing arg" $ do
@@ -685,8 +748,11 @@
               _STMT_(KDL.label "value" $ KDL.argWith $ void KDL.number <|> void KDL.string)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Expected arg 'value' with type: number or string"
+            [ "<input>:1:1:"
+            , "    • Expected arg 'value' with type: number or string"
+            , "  │"
+            , "1 │ foo"
+            , "  │ ^^^"
             ]
 
       it "shows label on invalid arg" $ do
@@ -695,8 +761,11 @@
               _STMT_(KDL.label "value" $ KDL.arg @Int)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg 'value'"
-            , "  Expected number, got: test"
+            [ "<input>:1:5:"
+            , "    • Expected number, got: test"
+            , "  │"
+            , "1 │ foo test"
+            , "  │     ^^^^"
             ]
 
     -- Most behaviors tested with `arg`
@@ -739,8 +808,11 @@
               _STMT_(_APOS_(KDL.argWith) ["VAL"] KDL.string)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            [ "<input>:1:5:"
+            , "    • Expected annotation to be one of [\"VAL\"], got: test"
+            , "  │"
+            , "1 │ foo (test)a"
+            , "  │     ^^^^^^^"
             ]
 
       it "supports backtracking annotations" $ do
@@ -783,8 +855,11 @@
               _STMT_(KDL.prop @Int "test")
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Expected prop 'test' with type: number"
+            [ "<input>:1:1:"
+            , "    • Expected prop 'test' with type: number"
+            , "  │"
+            , "1 │ foo 123"
+            , "  │ ^^^^^^^"
             ]
 
       it "fails if prop fails to parse" $ do
@@ -793,8 +868,11 @@
               _STMT_(KDL.prop @Int "hello")
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > prop hello"
-            , "  Expected number, got: world"
+            [ "<input>:1:11:"
+            , "    • Expected number, got: world"
+            , "  │"
+            , "1 │ foo hello=world"
+            , "  │           ^^^^^"
             ]
 
       it "fails if not all props are decoded" $ do
@@ -803,8 +881,11 @@
               _STMT_(KDL.prop @Int "a")
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Unexpected prop: b=2"
+            [ "<input>:1:1:"
+            , "    • Unexpected prop: b=2"
+            , "  │"
+            , "1 │ foo a=1 b=2"
+            , "  │ ^^^^^^^^^^^"
             ]
 
       it "shows expected types on missing prop" $ do
@@ -813,8 +894,11 @@
               _STMT_(KDL.propWith "test" $ void KDL.number <|> void KDL.string)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Expected prop 'test' with type: number or string"
+            [ "<input>:1:1:"
+            , "    • Expected prop 'test' with type: number or string"
+            , "  │"
+            , "1 │ foo 123"
+            , "  │ ^^^^^^^"
             ]
 
     -- Most behaviors tested with `prop`
@@ -857,8 +941,11 @@
               _STMT_(_APOS_(KDL.propWith) "a" ["VAL"] KDL.number)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > prop a"
-            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            [ "<input>:1:7:"
+            , "    • Expected annotation to be one of [\"VAL\"], got: test"
+            , "  │"
+            , "1 │ foo a=(test)1"
+            , "  │       ^^^^^^^"
             ]
 
     describe "remainingProps" $ do
@@ -884,8 +971,11 @@
               _STMT_(KDL.remainingProps @Int)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > prop c"
-            , "  Expected number, got: test"
+            [ "<input>:1:19:"
+            , "    • Expected number, got: test"
+            , "  │"
+            , "1 │ foo a=1 b=1 c=2 c=test"
+            , "  │                   ^^^^"
             ]
 
     -- Most behaviors tested with `remainingProps`
@@ -932,8 +1022,11 @@
               _STMT_(_APOS_(KDL.remainingPropsWith) ["VAL"] KDL.number)
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > prop b"
-            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            [ "<input>:1:16:"
+            , "    • Expected annotation to be one of [\"VAL\"], got: test"
+            , "  │"
+            , "1 │ foo a=(VAL)1 b=(test)2"
+            , "  │                ^^^^^^^"
             ]
 
     describe "children" $ do
@@ -971,8 +1064,11 @@
               _STMT_(KDL.children $ KDL.node @Node "bar")
         decodeNode "foo" decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0"
-            , "  Unexpected node: asdf #0"
+            [ "<input>:1:1:"
+            , "    • Unexpected node: asdf #0"
+            , "  │"
+            , "1 │ foo { asdf; bar; }"
+            , "  │ ^^^"
             ]
 
   describe "ValueDecoder" $ do
@@ -1003,8 +1099,11 @@
               _STMT_(KDL.argAtWith "foo" KDL.string)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected string, got: 1"
+            [ "<input>:1:5:"
+            , "    • Expected string, got: 1"
+            , "  │"
+            , "1 │ foo 1"
+            , "  │     ^"
             ]
 
     describe "number" $ do
@@ -1020,8 +1119,11 @@
               _STMT_(KDL.argAtWith "foo" KDL.number)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected number, got: asdf"
+            [ "<input>:1:5:"
+            , "    • Expected number, got: asdf"
+            , "  │"
+            , "1 │ foo asdf"
+            , "  │     ^^^^"
             ]
 
     describe "bool" $ do
@@ -1037,8 +1139,11 @@
               _STMT_(KDL.argAtWith "foo" KDL.bool)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected bool, got: 1"
+            [ "<input>:1:5:"
+            , "    • Expected bool, got: 1"
+            , "  │"
+            , "1 │ foo 1"
+            , "  │     ^"
             ]
 
     describe "null" $ do
@@ -1054,8 +1159,11 @@
               _STMT_(KDL.argAtWith "foo" KDL.null)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #0"
-            , "  Expected null, got: 1"
+            [ "<input>:1:5:"
+            , "    • Expected null, got: 1"
+            , "  │"
+            , "1 │ foo 1"
+            , "  │     ^"
             ]
 
   describe "Combinators" $ do
@@ -1074,9 +1182,12 @@
               _STMT_(KDL.nodeWith "foo" . KDL.many . KDL.argWith $ KDL.oneOf decodeVal)
         KDL.decodeWith decoder config
           `shouldSatisfy` decodeErrorMsg
-            [ "At: foo #0 > arg #1"
-            , "  Expected bool, got: hello"
-            , "  Expected number, got: hello"
+            [ "<input>:1:9:"
+            , "    • Expected number, got: hello"
+            , "    • Expected bool, got: hello"
+            , "  │"
+            , "1 │ foo 123 hello"
+            , "  │         ^^^^^"
             ]
 
     describe "option" $ do
@@ -1120,8 +1231,11 @@
             _STMT_(KDL.node @MyNode "foo")
       KDL.decodeWith decoder config
         `shouldSatisfy` decodeErrorMsg
-          [ "At: foo #0"
-          , "  Invalid argument: 100"
+          [ "<input>:1:1:"
+          , "    • Invalid argument: 100"
+          , "  │"
+          , "1 │ foo 100"
+          , "  │ ^^^^^^^"
           ]
 
     it "decodes valid type ann" $ do
@@ -1136,8 +1250,11 @@
             _STMT_(KDL.node @MyNode "foo")
       KDL.decodeWith decoder config
         `shouldSatisfy` decodeErrorMsg
-          [ "At: foo #0"
-          , "  Expected annotation to be one of [\"MyNode\"], got: bad"
+          [ "<input>:1:1:"
+          , "    • Expected annotation to be one of [\"MyNode\"], got: bad"
+          , "  │"
+          , "1 │ (bad)foo 1"
+          , "  │ ^^^^^^^^^^"
           ]
 
 newtype MyVal = MyVal Double
@@ -1165,8 +1282,11 @@
             _STMT_(KDL.argAt @MyVal "foo")
       KDL.decodeWith decoder config
         `shouldSatisfy` decodeErrorMsg
-          [ "At: foo #0 > arg #0"
-          , "  Invalid value: 100.0"
+          [ "<input>:1:5:"
+          , "    • Invalid value: 100.0"
+          , "  │"
+          , "1 │ foo 100.0"
+          , "  │     ^^^^^"
           ]
 
     it "decodes valid type ann" $ do
@@ -1181,6 +1301,9 @@
             _STMT_(KDL.argAt @MyVal "foo")
       KDL.decodeWith decoder config
         `shouldSatisfy` decodeErrorMsg
-          [ "At: foo #0 > arg #0"
-          , "  Expected annotation to be one of [\"MyVal\"], got: bad"
+          [ "<input>:1:5:"
+          , "    • Expected annotation to be one of [\"MyVal\"], got: bad"
+          , "  │"
+          , "1 │ foo (bad)1"
+          , "  │     ^^^^^^"
           ]
diff --git a/test/KDL/DecoderSpec.hs b/test/KDL/DecoderSpec.hs
--- a/test/KDL/DecoderSpec.hs
+++ b/test/KDL/DecoderSpec.hs
@@ -23,6 +23,14 @@
 
 spec :: Spec
 spec = do
+  spec_decodeWith
+  spec_decodeFileWith
+  spec_decodeDocWith
+  spec_errorMessages
+  spec_regressionTests
+
+spec_decodeWith :: Spec
+spec_decodeWith = do
   describe "decodeWith" $ do
     it "fails with helpful error if parsing fails" $ do
       let config = "foo 123=123"
@@ -49,6 +57,8 @@
               $ KDL.optional (KDL.prop @Text "a")
       KDL.decodeWith decoder config `shouldSatisfy` decodeErrorMsgSnapshot Nothing
 
+spec_decodeFileWith :: Spec
+spec_decodeFileWith = do
   describe "decodeFileWith" $ do
     it "fails with helpful error if parsing fails" $ do
       FixtureKdlFile file <- getFixture
@@ -78,8 +88,45 @@
               $ KDL.optional (KDL.prop @Text "a")
       KDL.decodeFileWith decoder file `shouldSatisfy` P.returns (decodeErrorMsgSnapshot (Just file))
 
-  spec_regressionTests
+spec_decodeDocWith :: Spec
+spec_decodeDocWith = do
+  describe "decodeDocWith" $ do
+    it "fails with user-defined error" $ do
+      let config = "foo -1"
+          decoder =
+            KDL.document . KDL.argAtWith "foo" $
+              KDL.withDecoder KDL.number $ \x -> do
+                when (x < 0) $ do
+                  KDL.failM $ "Got negative number: " <> (Text.pack . show) x
+                pure x
+      Right doc <- pure $ KDL.parseWith KDL.def config
+      KDL.decodeDocWith decoder doc
+        `shouldSatisfy` decodeErrorMsgSnapshot Nothing
 
+    it "shows context in deeply nested error" $ do
+      let config = "foo; foo { bar { baz; baz; baz; baz a=1; }; }"
+          decoder =
+            KDL.document
+              . (KDL.many . KDL.nodeWith "foo" . KDL.children)
+              . (KDL.many . KDL.nodeWith "bar" . KDL.children)
+              . (KDL.many . KDL.nodeWith "baz")
+              $ KDL.optional (KDL.prop @Text "a")
+      Right doc <- pure $ KDL.parseWith KDL.def config
+      KDL.decodeDocWith decoder doc
+        `shouldSatisfy` decodeErrorMsgSnapshot Nothing
+
+spec_errorMessages :: Spec
+spec_errorMessages = do
+  describe "Error messages" $ do
+    it "only shows first line when context spans multiple lines" $ do
+      let config = "foo \\\n  1"
+          decoder =
+            KDL.document . KDL.nodeWith "foo" $ do
+              _ <- KDL.arg @Int
+              _ <- KDL.children $ KDL.argAt @Int "bar"
+              pure ()
+      KDL.decodeWith decoder config `shouldSatisfy` decodeErrorMsgSnapshot Nothing
+
 newtype FixtureKdlFile = FixtureKdlFile FilePath
 
 instance Fixture FixtureKdlFile where
@@ -110,6 +157,9 @@
                   KDL.fail "Invalid username"
       KDL.decodeWith decoder config
         `shouldSatisfy` decodeErrorMsg
-          [ "At: user #1 > arg #0"
-          , "  Invalid username"
+          [ "<input>:1:30:"
+          , "    • Invalid username"
+          , "  │"
+          , "1 │ user a { foo { bar } }; user a1"
+          , "  │                              ^^"
           ]
diff --git a/test/KDL/__snapshots__/DecoderSpec.snap.md b/test/KDL/__snapshots__/DecoderSpec.snap.md
--- a/test/KDL/__snapshots__/DecoderSpec.snap.md
+++ b/test/KDL/__snapshots__/DecoderSpec.snap.md
@@ -1,9 +1,9 @@
 # test/KDL/DecoderSpec.hs
 
-## decodeFileWith / fails with helpful error if parsing fails
+## decodeWith ≫ fails with helpful error if parsing fails
 
 ```
-test_config.kdl:1:8:
+1:8:
   |
 1 | foo 123=123
   |        ^
@@ -11,26 +11,30 @@
 expecting children block, decimal point, end of node, exponent, or node prop or arg
 ```
 
-## decodeFileWith / fails with user-defined error
+## decodeWith ≫ fails with user-defined error
 
 ```
-Failed to decode test_config.kdl:
-At: foo #0 > arg #0
-  Got negative number: -1.0
+<input>:1:5:
+    • Got negative number: -1.0
+  │
+1 │ foo -1
+  │     ^^
 ```
 
-## decodeFileWith / shows context in deeply nested error
+## decodeWith ≫ shows context in deeply nested error
 
 ```
-Failed to decode test_config.kdl:
-At: foo #1 > bar #0 > baz #3 > prop a
-  Expected string, got: 1
+<input>:1:39:
+    • Expected string, got: 1
+  │
+1 │ foo; foo { bar { baz; baz; baz; baz a=1; }; }
+  │                                       ^
 ```
 
-## decodeWith / fails with helpful error if parsing fails
+## decodeFileWith ≫ fails with helpful error if parsing fails
 
 ```
-1:8:
+test_config.kdl:1:8:
   |
 1 | foo 123=123
   |        ^
@@ -38,16 +42,46 @@
 expecting children block, decimal point, end of node, exponent, or node prop or arg
 ```
 
-## decodeWith / fails with user-defined error
+## decodeFileWith ≫ fails with user-defined error
 
 ```
+test_config.kdl:1:5:
+    • Got negative number: -1.0
+  │
+1 │ foo -1
+  │     ^^
+```
+
+## decodeFileWith ≫ shows context in deeply nested error
+
+```
+test_config.kdl:1:39:
+    • Expected string, got: 1
+  │
+1 │ foo; foo { bar { baz; baz; baz; baz a=1; }; }
+  │                                       ^
+```
+
+## decodeDocWith ≫ fails with user-defined error
+
+```
 At: foo #0 > arg #0
-  Got negative number: -1.0
+└─ Got negative number: -1.0
 ```
 
-## decodeWith / shows context in deeply nested error
+## decodeDocWith ≫ shows context in deeply nested error
 
 ```
 At: foo #1 > bar #0 > baz #3 > prop a
-  Expected string, got: 1
+└─ Expected string, got: 1
+```
+
+## Error messages ≫ only shows first line when context spans multiple lines
+
+```
+<input>:1:1:
+    • Expected node: bar
+  │
+1 │ foo \
+  │ ^^^^^
 ```
diff --git a/test/KDL/__snapshots__/ParserSpec.snap.md b/test/KDL/__snapshots__/ParserSpec.snap.md
--- a/test/KDL/__snapshots__/ParserSpec.snap.md
+++ b/test/KDL/__snapshots__/ParserSpec.snap.md
@@ -1,17 +1,6 @@
 # test/KDL/ParserSpec.hs
 
-## parse / error messages / Unquoted numeric prop name
-
-```
-1:8:
-  |
-1 | foo 123=123
-  |        ^
-unexpected '='
-expecting children block, decimal point, end of node, exponent, or node prop or arg
-```
-
-## parse / parses a KDL document
+## parse ≫ parses a KDL document
 
 ```haskell
 NodeList
@@ -157,7 +146,18 @@
   }
 ```
 
-## parseWith / parses a KDL document with spans
+## parse ≫ error messages ≫ Unquoted numeric prop name
+
+```
+1:8:
+  |
+1 | foo 123=123
+  |        ^
+unexpected '='
+expecting children block, decimal point, end of node, exponent, or node prop or arg
+```
+
+## parseWith ≫ parses a KDL document with spans
 
 ```haskell
 NodeList
diff --git a/test/KDL/__snapshots__/RenderSpec.snap.md b/test/KDL/__snapshots__/RenderSpec.snap.md
--- a/test/KDL/__snapshots__/RenderSpec.snap.md
+++ b/test/KDL/__snapshots__/RenderSpec.snap.md
@@ -1,6 +1,6 @@
 # test/KDL/RenderSpec.hs
 
-## render / default formatting / renders correctly
+## render ≫ default formatting ≫ renders correctly
 
 ```
 (Foo)foo (Foo)123.0 a=(Foo)123.0 test b=test {
