diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## [0.0.0.4] - 2026-08-24
+
+### Changed
+
+* Decoding errors now name the key that failed, and say how many values were
+  found when exactly one was expected.
+
 ## [0.0.0.3] - 2026-07-14
 
 ### Changed
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/autodocodec-servant-multipart.cabal b/autodocodec-servant-multipart.cabal
--- a/autodocodec-servant-multipart.cabal
+++ b/autodocodec-servant-multipart.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           autodocodec-servant-multipart
-version:        0.0.0.3
+version:        0.0.0.4
 synopsis:       Autodocodec interpreters for Servant Multipart
 homepage:       https://github.com/NorfairKing/autodocodec#readme
 bug-reports:    https://github.com/NorfairKing/autodocodec/issues
@@ -30,6 +30,7 @@
       Paths_autodocodec_servant_multipart
   hs-source-dirs:
       src
+  ghc-options: -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates -Wpartial-fields -Widentities -Wredundant-constraints -Wcpp-undef
   build-depends:
       aeson
     , autodocodec >=0.6.0.0
diff --git a/src/Autodocodec/Multipart.hs b/src/Autodocodec/Multipart.hs
--- a/src/Autodocodec/Multipart.hs
+++ b/src/Autodocodec/Multipart.hs
@@ -13,6 +13,7 @@
 import Autodocodec
 import Data.Aeson as JSON
 import Data.Aeson.Types as JSON
+import Data.Bifunctor (first)
 import qualified Data.ByteString.Lazy as LB
 import Data.Coerce (coerce)
 import Data.Foldable
@@ -127,6 +128,12 @@
 fromMultipartVia :: ObjectCodec void a -> MultipartData tag -> Either String a
 fromMultipartVia = flip go
   where
+    -- Name the key in whatever failed under it. This error reaches whoever
+    -- submitted the form, often as an HTTP response body, so "which key" is the
+    -- first thing it has to say.
+    inKey :: Text -> Either String b -> Either String b
+    inKey key = first $ \err -> concat ["Failed to parse key ", show key, ": ", err]
+
     go :: MultipartData tag -> ObjectCodec void a -> Either String a
     go mpd = \case
       BimapCodec from _ c -> go mpd c >>= from
@@ -154,20 +161,20 @@
         case HashMap.lookup discriminatorValue m of
           Nothing -> Left $ "Unexpected discriminator value: " <> show discriminatorValue
           Just (_, c) -> go mpd c
-      RequiredKeyCodec key vc _ -> do
+      RequiredKeyCodec key vc _ -> inKey key $ do
         values <- lookupLInput key mpd
         coerce $ goValue values vc
-      OptionalKeyCodec key vc _ -> do
+      OptionalKeyCodec key vc _ -> inKey key $ do
         values <- lookupLInput key mpd
         coerce $ case values of
           [] -> pure Nothing
           _ -> Just <$> goValue values vc
-      OptionalKeyWithDefaultCodec key vc defaultValue _ -> do
+      OptionalKeyWithDefaultCodec key vc defaultValue _ -> inKey key $ do
         values <- lookupLInput key mpd
         coerce $ case values of
           [] -> pure defaultValue
           _ -> goValue values vc
-      OptionalKeyWithOmittedDefaultCodec key vc defaultValue _ -> do
+      OptionalKeyWithOmittedDefaultCodec key vc defaultValue _ -> inKey key $ do
         values <- lookupLInput key mpd
         coerce $ case values of
           [] -> pure defaultValue
@@ -202,7 +209,8 @@
       ArrayOfCodec _ vc -> coerce $ V.fromList <$> mapM (`goSingleValue` vc) (toList ts)
       vc -> case ts of
         [t] -> goSingleValue t vc
-        _ -> Left "Expected exactly one value."
+        [] -> Left "expected exactly one value, found none."
+        _ -> Left $ concat ["expected exactly one value, found ", show (length ts), "."]
 
     goSingleValue :: Text -> ValueCodec void a -> Either String a
     goSingleValue t = \case
