diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+# 0.1.1.0
+
+- Accept bare MULTIPOINT coordinates mixed with `EMPTY`, including DuckDB's
+  `ST_AsText` output. Previously this syntax failed to parse. Nonempty members
+  must still use one spelling throughout. The writer keeps its parenthesized
+  form, and layout inference is unchanged.
+- Add `validateWKB` to check complete ISO WKB without constructing geometry
+  values or coordinate buffers. It checks the same counts, types, line lengths,
+  and polygon rules as `decodeWKB`. Previously callers had to decode and discard
+  geometry values to perform these checks.
+- Correct the DuckDB interoperability notes. DuckDB can store mixed-layout WKB
+  with `ST_GeomFromWKB` and return it with `ST_AsWKB`; its WKT reader and writer
+  require a common layout.
+
 # 0.1.0.0
 
 Initial release.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -127,7 +127,12 @@
 `encodeWKB` writes little-endian ISO WKB. `decodeWKB` accepts either byte order,
 including mixed byte orders in collections. The WKT decoder accepts explicit
 or inferred coordinate layouts, lowercase keywords, and scientific notation.
-All four codec functions return `Either String` and reject invalid construction.
+It accepts DuckDB's bare MULTIPOINT coordinates mixed with `EMPTY`. Nonempty
+members must consistently use or omit parentheses. The writer keeps its
+parenthesized MULTIPOINT form.
+The codec functions return `Either String` and reject invalid construction.
+`validateWKB` checks the same WKB rules as `decodeWKB` without constructing
+geometry values or coordinate buffers.
 The decoders also reject trailing input.
 
 Finite ordinates retain their exact bits through WKB and through text produced
@@ -138,7 +143,9 @@
 
 WKT collections use a parent dimension tag when their members share one output
 layout. Mixed-layout collections omit it and retain child tags. That form is
-an extension accepted by this library and GEOS; DuckDB rejects it.
+an extension accepted by this library and GEOS; DuckDB's WKT reader rejects it.
+DuckDB can store mixed-layout WKB with `ST_GeomFromWKB` and return it with
+`ST_AsWKB`. Its `ST_AsText` function rejects those mixed-layout geometries.
 
 ## Development
 
diff --git a/docs/GEOS-DIFFERENCES.md b/docs/GEOS-DIFFERENCES.md
--- a/docs/GEOS-DIFFERENCES.md
+++ b/docs/GEOS-DIFFERENCES.md
@@ -15,6 +15,7 @@
 | Representative points | Use the first available polygon, interior line vertex, endpoint, or point, in that order. Empty components are skipped. | Selection can depend on centroid distance, interval width, and empty members. |
 | Point observers | Preserve the stored layout and every ordinate, including NaN Z/M. | Extracted points can lose dimensions whose ordinate is NaN. |
 | Mixed WKT collections | Each child carries its own dimension tag; the parent has none. | A parent tag can conflict with a child and make the writer's output unreadable. |
+| Bare MULTIPOINT with EMPTY | Accepts empty members before, between, or after bare coordinates, as emitted by DuckDB. Nonempty members must use one spelling throughout. | Requires parenthesized nonempty members when EMPTY is present. |
 | WKT numbers | Shortest scientific notation that decodes to the same `Double`. | Decimal formatting differs. |
 | Numerical reductions | Compensated centroid sums retain small contributions during cancellation. | Evaluation order and final floating-point digits can differ. |
 
@@ -152,12 +153,17 @@
 standalone empty containers use XY.
 
 Mixed-layout collection WKT omits the parent tag and retains each child's tag.
-This extends the OGC grammar in section 7. GEOS accepts this form, but DuckDB
-requires one layout across all members for both WKT and WKB. Use a common
-layout for DuckDB interchange.
+This extends the OGC grammar in section 7. GEOS accepts this form. DuckDB's
+WKT reader and `ST_AsText` require one layout across all members. DuckDB can
+store mixed-layout WKB through `ST_GeomFromWKB` and return it through
+`ST_AsWKB`, but `ST_AsText` rejects that geometry. Use a common layout when
+DuckDB interchange includes WKT.
 
 The WKT decoder accepts attached tags such as `POINTZ`, both multipoint
-syntaxes, signed numbers, fractions, and exponents. Whitespace between ordinates
+syntaxes, signed numbers, fractions, and exponents. It also accepts `EMPTY`
+with bare MULTIPOINT coordinates, including DuckDB's `ST_AsText` output. This
+syntax is broader than GEOS 3.13.1. Mixing parenthesized and bare nonempty
+members remains an error. Whitespace between ordinates
 is required: space, tab, CR, or LF. The decoder rounds each decimal ordinate
 to the nearest `Double`. Underflow produces signed zero; overflow produces infinity.
 
diff --git a/geometry-simple.cabal b/geometry-simple.cabal
--- a/geometry-simple.cabal
+++ b/geometry-simple.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.4
 name: geometry-simple
-version: 0.1.0.0
+version: 0.1.1.0
 license: MIT
 license-file: LICENSE
 author: Matthias Pall Gissurarson
diff --git a/src/Data/Geometry/Internal.hs b/src/Data/Geometry/Internal.hs
--- a/src/Data/Geometry/Internal.hs
+++ b/src/Data/Geometry/Internal.hs
@@ -326,21 +326,32 @@
 validateLine :: Coordinates -> Either String ()
 validateLine points = when (withCoordinates U.length points == 1) (Left "Geometry line must have zero or at least two coordinates")
 
--- | Rings have zero or at least three coordinates and close in X and Y.
+{- | Check a ring's size and XY closure. Empty rings are valid. Nonempty rings
+need at least three coordinates. Endpoints are not evaluated for shorter rings.
+-}
+validateRing :: (Coordinate c) => Int -> c -> c -> Either String ()
+{-# INLINE validateRing #-}
+validateRing count first lastPoint
+    | count == 0 = pure ()
+    | count < 3 = Left "Geometry ring must have zero or at least three coordinates"
+    | otherwise = unless (x == x' && y == y') (Left "Geometry ring is not closed")
+  where
+    (x, y, _, _) = coordinateComponents first
+    (x', y', _, _) = coordinateComponents lastPoint
+
+-- | Reject nonempty holes when the shell is empty. Arguments indicate emptiness.
+validatePolygonEmptiness :: Bool -> Bool -> Either String ()
+validatePolygonEmptiness shellEmpty holesEmpty =
+    when (shellEmpty && not holesEmpty) (Left "Geometry polygon has an empty shell and nonempty holes")
+
+-- | Check ring sizes, XY closure, and polygon emptiness.
 validatePolygon :: PolygonRings -> Either String ()
 validatePolygon (PolygonRings shell holes) = do
-    validateRing shell
-    V.mapM_ validateRing holes
-    when (coordinatesEmpty shell && not (V.all coordinatesEmpty holes)) $
-        Left "Geometry polygon has an empty shell and nonempty holes"
+    checkRing shell
+    V.mapM_ checkRing holes
+    validatePolygonEmptiness (coordinatesEmpty shell) (V.all coordinatesEmpty holes)
   where
-    validateRing = withCoordinates $ \points -> case U.length points of
-        0 -> pure ()
-        count | count < 3 -> Left "Geometry ring must have zero or at least three coordinates"
-        _ ->
-            let (x, y, _, _) = coordinateComponents (U.head points)
-                (x', y', _, _) = coordinateComponents (U.last points)
-             in unless (x == x' && y == y') (Left "Geometry ring is not closed")
+    checkRing = withCoordinates $ \points -> validateRing (U.length points) (U.head points) (U.last points)
 
 -- | Test whether a geometry has no stored coordinates.
 geometryEmpty :: Geometry -> Bool
diff --git a/src/Data/Geometry/WKB.hs b/src/Data/Geometry/WKB.hs
--- a/src/Data/Geometry/WKB.hs
+++ b/src/Data/Geometry/WKB.hs
@@ -8,9 +8,9 @@
 line lengths, ring closure, counts, and type codes. They accept non-finite
 ordinates and do not validate polygon topology. EWKB and SRIDs are not supported.
 -}
-module Data.Geometry.WKB (decodeWKB, encodeWKB) where
+module Data.Geometry.WKB (decodeWKB, validateWKB, encodeWKB) where
 
-import Control.Monad (unless, when)
+import Control.Monad (replicateM_, unless, void, when)
 import Control.Monad.ST (runST)
 import Data.Binary.Get (Get, bytesRead, getByteString, getDoublebe, getDoublele, getWord32be, getWord32le, getWord8, lookAhead, runGetOrFail, skip)
 import Data.Bits (shiftL, (.|.))
@@ -39,6 +39,15 @@
 decodeWKB :: ByteString -> Either String Geometry
 decodeWKB bytes = runDecoder (getGeometry (fromIntegral (BS.length bytes))) bytes
 
+{- | Check one complete ISO WKB geometry without constructing geometry values
+or coordinate buffers. Apply the same construction and format checks as
+'decodeWKB'. Non-finite ordinates are accepted; polygon topology is not checked.
+
+@since 0.1.1.0
+-}
+validateWKB :: ByteString -> Either String ()
+validateWKB bytes = runDecoder (skipGeometry (fromIntegral (BS.length bytes))) bytes
+
 {- | Encode little-endian ISO WKB. Child headers retain their layouts.
 Polygon rings use their combined layout, with NaN for absent Z or M ordinates.
 Finite ordinates retain their exact bits, including negative zero.
@@ -94,7 +103,7 @@
         1 -> PointGeometry <$> getPoint little dimensions
         2 -> LineString <$> getCoordinates total little dimensions
         3 -> Polygon <$> getPolygon total little dimensions
-        4 -> MultiPoint <$> getMultiPoints total little
+        4 -> MultiPoint <$> getMultiPoints True total little
         5 -> do
             count <- getCount total little 9
             MultiLineString <$> V.replicateM count (getChild 2 (getCoordinates total))
@@ -105,6 +114,29 @@
             count <- getCount total little 9
             GeometryCollection <$> V.replicateM count (do child <- getGeometry total; pure $! child)
 
+-- | Traverse checked geometry payloads without retaining decoded coordinates.
+skipGeometry :: Int64 -> Get ()
+skipGeometry total = do
+    (little, dimensions, family) <- getHeader
+    case family of
+        1 -> skip (8 * dimensionCount dimensions)
+        2 -> void (getCoordinateBytes total little dimensions)
+        3 -> skipPolygon total little dimensions
+        4 -> void (getMultiPoints False total little)
+        5 -> getCount total little 9 >>= \count -> replicateM_ count (getChild 2 (\order layout -> void (getCoordinateBytes total order layout)))
+        6 -> getCount total little 9 >>= \count -> replicateM_ count (getChild 3 (skipPolygon total))
+        _ -> getCount total little 9 >>= \count -> replicateM_ count (skipGeometry total)
+
+-- | Check polygon rings and the empty-shell rule without retaining ring buffers.
+skipPolygon :: Int64 -> Bool -> Dimensions -> Get ()
+skipPolygon total little dimensions = do
+    count <- getCount total little 4
+    when (count > 0) $ do
+        shell <- getRing total little dimensions
+        replicateM_ (count - 1) $ do
+            hole <- getRing total little dimensions
+            either fail pure (validatePolygonEmptiness (BS.null shell) (BS.null hole))
+
 -- | Check a multi-geometry child header before reading its typed body.
 getChild :: Word32 -> (Bool -> Dimensions -> Get a) -> Get a
 getChild expected body = do
@@ -124,6 +156,17 @@
     either fail pure (validatePolygon rings)
     pure rings
 
+-- | Read a ring's checked bytes and verify its length and XY closure.
+getRing :: Int64 -> Bool -> Dimensions -> Get ByteString
+getRing total little dimensions = do
+    bytes <- getCoordinateBytes total little dimensions
+    let stride = 8 * dimensionCount dimensions
+        count = BS.length bytes `div` stride
+        first = coordinateAt little bytes 0 :: XY
+        lastPoint = coordinateAt little bytes ((count - 1) * stride)
+    either fail pure (validateRing count first lastPoint)
+    pure bytes
+
 -- | Read point ordinates before applying WKB's XY-NaN empty convention.
 getPoint :: Bool -> Dimensions -> Get Point
 getPoint little dimensions = do
@@ -139,39 +182,46 @@
 
 -- | Read one checked coordinate block into its typed unboxed buffer.
 getCoordinates :: Int64 -> Bool -> Dimensions -> Get Coordinates
-getCoordinates total little dimensions = do
+getCoordinates total little dimensions = coordinatesFromBytes little dimensions <$> getCoordinateBytes total little dimensions
+
+-- | Check a line's count and return its complete coordinate block.
+getCoordinateBytes :: Int64 -> Bool -> Dimensions -> Get ByteString
+getCoordinateBytes total little dimensions = do
     let stride = 8 * dimensionCount dimensions
     count <- getCount total little (fromIntegral stride)
     when (count == 1) (fail "Geometry line must have zero or at least two coordinates")
-    if count == 0
-        then pure (emptyCoordinates dimensions)
-        else do
-            bytes <- getByteString (count * stride)
-            let values :: (Coordinate c) => U.Vector c
-                values = U.generate count (\i -> coordinateAt little bytes (i * stride))
-            pure $ case dimensions of
-                DimXY -> CoordinatesXY values
-                DimXYZ -> CoordinatesXYZ values
-                DimXYM -> CoordinatesXYM values
-                DimXYZM -> CoordinatesXYZM values
+    getByteString (count * stride)
 
+-- | Materialize a checked coordinate block in its declared layout.
+coordinatesFromBytes :: Bool -> Dimensions -> ByteString -> Coordinates
+coordinatesFromBytes little dimensions bytes = case dimensions of
+    DimXY -> CoordinatesXY values
+    DimXYZ -> CoordinatesXYZ values
+    DimXYM -> CoordinatesXYM values
+    DimXYZM -> CoordinatesXYZM values
+  where
+    stride = 8 * dimensionCount dimensions
+    count = BS.length bytes `div` stride
+    values :: (Coordinate c) => U.Vector c
+    values = U.generate count (\i -> coordinateAt little bytes (i * stride))
+
 -- | Read point children without a separate Get action for each child.
-getMultiPoints :: Int64 -> Bool -> Get (U.Vector Point)
-getMultiPoints total little = do
+getMultiPoints :: Bool -> Int64 -> Bool -> Get (U.Vector Point)
+getMultiPoints materialize total little = do
     count <- getCount total little 21
     if count == 0
         then pure U.empty
         else do
             consumed <- bytesRead
             bytes <- lookAhead (getByteString (fromIntegral (total - consumed)))
-            (points, size) <- either fail pure (multiPointsAt count bytes)
+            (points, size) <- either fail pure (multiPointsAt materialize count bytes)
             skip size
             pure points
 
--- | Read checked variable-width point records into one unboxed vector.
-multiPointsAt :: Int -> ByteString -> Either String (U.Vector Point, Int)
-multiPointsAt count bytes = runST $ do
-    target <- UM.new count
+-- | Check variable-width point records and optionally retain their coordinates.
+multiPointsAt :: Bool -> Int -> ByteString -> Either String (U.Vector Point, Int)
+multiPointsAt materialize count bytes = runST $ do
+    target <- UM.new (if materialize then count else 0)
     let go index offset
             | index == count = do
                 points <- U.unsafeFreeze target
@@ -185,7 +235,7 @@
             | dimensionTag > 3 || family < 1 || family > 7 = pure (Left "Geometry WKB has an unsupported type")
             | family /= 1 = pure (Left "Geometry WKB multi child has the wrong family")
             | stride > BS.length bytes - offset = pure (Left "Geometry WKB point exceeds the remaining bytes")
-            | otherwise = UM.write target index value >> go (index + 1) (offset + stride)
+            | otherwise = when materialize (UM.write target index value) >> go (index + 1) (offset + stride)
           where
             (dimensionTag, family) = word32At little bytes (offset + 1) `quotRem` 1000
             dimensions = toEnum (fromIntegral dimensionTag)
diff --git a/src/Data/Geometry/WKT.hs b/src/Data/Geometry/WKT.hs
--- a/src/Data/Geometry/WKT.hs
+++ b/src/Data/Geometry/WKT.hs
@@ -8,6 +8,8 @@
 it infers XY, XYZ, or XYZM from the number of ordinates. XYM requires an M tag.
 Within a multi-geometry, the first coordinate sets the layout for the remaining
 coordinates. Empty members before that coordinate retain XY.
+MULTIPOINT accepts EMPTY beside bare coordinates, as emitted by DuckDB.
+Nonempty members must consistently use or omit parentheses.
 The codecs check line lengths and ring closure, but not polygon topology.
 NaN and infinity are accepted. EWKT and SRIDs are not supported.
 -}
@@ -192,10 +194,10 @@
             Nothing -> False
             Just (c, _) -> c /= ',' && c /= ')'
 
--- | Read a point body. Bare MULTIPOINT coordinates cannot contain EMPTY.
+-- | Read a point body. MULTIPOINT coordinates can omit parentheses.
 point :: Bool -> Maybe Dimensions -> Parser (Point, Maybe Dimensions)
 point parenthesized current = do
-    empty <- if parenthesized then emptyKeyword else pure False
+    empty <- emptyKeyword
     if empty
         then pure (EmptyPoint (fromMaybe DimXY current), current)
         else do
@@ -238,15 +240,22 @@
     lift (validatePolygon values)
     pure (values, dimensions)
 
--- | Use one MULTIPOINT spelling throughout its body.
+-- | Use the first nonempty point's spelling throughout a MULTIPOINT body.
 multiPoint :: Maybe Dimensions -> Parser (U.Vector Point, Maybe Dimensions)
 multiPoint current = do
-    spaces
-    input <- get
-    -- Inspect only the first token. Uppercasing the rest of the input would be quadratic.
-    let first = Text.dropWhile whitespace (Text.drop 1 input)
-        parenthesized = Text.isPrefixOf "(" first || Text.toUpper (Text.takeWhile letter first) == "EMPTY"
+    parenthesized <- lookAheadParser $ do
+        empty <- emptyKeyword
+        if empty then pure False else symbol '(' >> firstNonempty
     unboxedSequence current (point parenthesized)
+  where
+    -- Scan leading empty members once. Their layouts are assigned during parsing.
+    firstNonempty = do
+        empty <- emptyKeyword
+        if empty
+            then do
+                finished <- delimiter
+                if finished then pure False else firstNonempty
+            else Text.isPrefixOf "(" <$> get
 
 {- | Read EMPTY or a parenthesized sequence into a boxed vector. Carry the
 inferred layout from each element to the next. A list keeps deep nesting
