diff --git a/dataframe-parquet.cabal b/dataframe-parquet.cabal
--- a/dataframe-parquet.cabal
+++ b/dataframe-parquet.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.4
 name:               dataframe-parquet
-version:            1.5.1.0
+version:            1.6.0.0
 synopsis:           Parquet reader and writer for the dataframe ecosystem.
 description:
     @DataFrame.IO.Parquet@ — pure-Haskell Parquet 2.0 reader and writer
@@ -60,8 +60,8 @@
     build-depends:      base >= 4 && < 5,
                         bytestring >= 0.11 && < 0.14,
                         containers >= 0.6.7 && < 0.10,
-                        dataframe-core >= 2.5 && < 2.6,
-                        dataframe-operations >= 2.5 && < 2.6,
+                        dataframe-core >= 2.6 && < 2.7,
+                        dataframe-operations >= 2.6 && < 2.7,
                         dataframe-parsing >= 2.2 && < 2.3,
                         primitive >= 0.7 && < 0.11,
                         directory >= 1.3.0.0 && < 2,
@@ -85,7 +85,8 @@
     hs-source-dirs:     tests
     build-depends:      base >= 4 && < 5,
                         bytestring >= 0.11 && < 0.14,
-                        dataframe-core >= 2.5 && < 2.6,
+                        dataframe-core >= 2.6 && < 2.7,
+                        dataframe-operations >= 2.6 && < 2.7,
                         dataframe-parquet,
                         directory >= 1.3.0.0 && < 2,
                         filepath >= 1.4 && < 2,
@@ -100,7 +101,7 @@
     other-modules:      DataFrame10GB
     hs-source-dirs:     stress
     build-depends:      base >= 4 && < 5,
-                        dataframe-core >= 2.5 && < 2.6,
+                        dataframe-core >= 2.6 && < 2.7,
                         dataframe-parquet,
                         filepath >= 1.4 && < 2,
                         temporary >= 1.3 && < 1.5,
@@ -119,7 +120,7 @@
     build-depends:      base >= 4 && < 5,
                         criterion >= 1 && < 2,
                         deepseq >= 1.4 && < 2,
-                        dataframe-core >= 2.5 && < 2.6,
+                        dataframe-core >= 2.6 && < 2.7,
                         dataframe-parquet,
                         directory >= 1.3 && < 2,
                         filepath >= 1.4 && < 2,
diff --git a/src/DataFrame/IO/Parquet.hs b/src/DataFrame/IO/Parquet.hs
--- a/src/DataFrame/IO/Parquet.hs
+++ b/src/DataFrame/IO/Parquet.hs
@@ -62,8 +62,11 @@
     ForceNonSeekable,
     withFileBufferedOrSeekable,
     _readParquetWithOpts,
+    readParquetWithMetadata,
+    FileMetadata,
 ) where
 
+import Control.Concurrent (getNumCapabilities)
 import Control.Exception (throw)
 import Control.Monad
 import Control.Monad.IO.Class (MonadIO (..))
@@ -72,7 +75,9 @@
 import qualified Data.ByteString as BS
 import Data.Either (fromRight)
 import Data.Functor ((<&>))
+import Data.IORef (atomicModifyIORef', newIORef)
 import Data.Int (Int32, Int64)
+import Data.Maybe (fromJust, fromMaybe)
 #if !MIN_VERSION_base(4,20,0)
 import Data.List (foldl', transpose)
 #else
@@ -83,9 +88,21 @@
 import qualified Data.Text as T
 import Data.Time.Calendar (Day (ModifiedJulianDay))
 import Data.Time.Clock (UTCTime (UTCTime), picosecondsToDiffTime)
+import Data.Type.Equality (testEquality, (:~:) (Refl))
 import qualified Data.Vector as Vector
+import qualified Data.Vector.Mutable as MVector
 import qualified Data.Vector.Unboxed as VU
+import qualified Data.Vector.Unboxed.Mutable as VUM
 import DataFrame.Errors (DataFrameException (ColumnsNotFoundException))
+import DataFrame.IO.Parquet.Dictionary (
+    DictVals,
+    dictDouble,
+    dictFloat,
+    dictInt32,
+    dictInt64,
+    word32At,
+    word64At,
+ )
 import DataFrame.IO.Parquet.Page (
     PageDecoder,
     UnboxedPageDecoder,
@@ -93,6 +110,7 @@
     appendStringPageIO,
     boolDecoder,
     byteArrayDecoder,
+    decodeRequiredNumericInto,
     doubleDecoder,
     fixedLenByteArrayDecoder,
     floatDecoder,
@@ -109,11 +127,13 @@
  )
 import DataFrame.IO.Parquet.Thrift (
     ColumnChunk (..),
+    ColumnMetaData (..),
     DecimalType (..),
     FileMetadata (..),
     KeyValue (..),
     LogicalType (..),
     RowGroup (..),
+    Statistics (..),
     ThriftType (..),
     TimeUnit (..),
     TimestampType (..),
@@ -139,18 +159,26 @@
     writeParquetWithOptions,
  )
 import DataFrame.IO.Utils.RandomAccess (
+    ChunkBuffer (..),
     RandomAccess (..),
+    Range (Range),
     ReaderIO (runReaderIO),
  )
 import DataFrame.Internal.Column (Column, Columnable)
 import qualified DataFrame.Internal.Column as DI
 import DataFrame.Internal.Column.Builder (
+    concatColumnsIO,
     freezeTextChunk,
     mergeTextChunks,
     newTextBuilder,
  )
+import DataFrame.Internal.Control.Concurrent (forkJoin_)
 import DataFrame.Internal.DataFrame (DataFrame (..))
-import DataFrame.Internal.Expression (Expr, getColumns)
+import DataFrame.Internal.Expression (
+    BinaryOp (binaryName),
+    Expr (..),
+    getColumns,
+ )
 import DataFrame.Operations.Merge ()
 import qualified DataFrame.Operations.Subset as DS
 import DataFrame.Schema (
@@ -159,52 +187,32 @@
     makeSchema,
     schemaType,
  )
+import GHC.Float (castWord32ToFloat, castWord64ToDouble)
 import qualified Pinch
 import System.Directory (doesDirectoryExist)
 import System.FilePath ((</>))
 import System.FilePath.Glob (glob)
 import System.IO (IOMode (ReadMode))
-
--- Options -----------------------------------------------------------------
-
-{- | Options for reading Parquet data.
-
-These options are applied in this order:
-
-1. predicate filtering
-2. column projection
-3. row range
-4. safe column promotion
+import Type.Reflection (typeRep)
 
-Column selection for @selectedColumns@ uses leaf column names only.
--}
+-- | Options for reading Parquet data.
 data ParquetReadOptions = ParquetReadOptions
     { selectedColumns :: Maybe [T.Text]
-    {- ^ Columns to keep in the final dataframe. If set, only these columns are returned.
-    Predicate-referenced columns are read automatically when needed and projected out after filtering.
-    -}
+    -- ^ Columns to keep in the final dataframe.
     , predicate :: Maybe (Expr Bool)
     -- ^ Optional row filter expression applied before projection.
     , rowRange :: Maybe (Int, Int)
-    -- ^ Optional row slice @(start, end)@ with start-inclusive/end-exclusive semantics.
+    -- ^ Optional row slice @(start, end)@, start-inclusive, end-exclusive.
     , safeColumns :: Bool
-    -- ^ When True, every column is promoted to OptionalColumn after read, regardless of nullability in the schema.
+    -- ^ Promote every column to OptionalColumn after read.
+    , widenIntegers :: Bool
+    -- ^ Decode INT32 and INT64 columns as 'Int'.
+    , parallel :: Bool
+    -- ^ Decode row groups concurrently using every RTS capability.
     }
     deriving (Show)
 
-{- | Default Parquet read options.
-
-Equivalent to:
-
-@
-ParquetReadOptions
-    { selectedColumns = Nothing
-    , predicate = Nothing
-    , rowRange = Nothing
-    , safeColumns = False
-    }
-@
--}
+-- | Default Parquet read options.
 defaultParquetReadOptions :: ParquetReadOptions
 defaultParquetReadOptions =
     ParquetReadOptions
@@ -212,63 +220,287 @@
         , predicate = Nothing
         , rowRange = Nothing
         , safeColumns = False
+        , widenIntegers = False
+        , parallel = True
         }
 
--- Public API --------------------------------------------------------------
-
-{- | Read a parquet file from path and load it into a dataframe.
-
-==== __Example__
-@
-ghci> D.readParquet ".\/data\/mtcars.parquet"
-@
--}
+-- | Read a parquet file from path and load it into a dataframe.
 readParquet :: FilePath -> IO DataFrame
 readParquet = readParquetWithOpts defaultParquetReadOptions
 
-{- | Read a Parquet file using explicit read options.
-
-==== __Example__
-@
-ghci> D.readParquetWithOpts
-ghci|   (D.defaultParquetReadOptions{D.selectedColumns = Just ["id"], D.rowRange = Just (0, 10)})
-ghci|   "./tests/data/alltypes_plain.parquet"
-@
-
-When @selectedColumns@ is set and @predicate@ references other columns, those predicate columns
-are auto-included for decoding, then projected back to the requested output columns.
--}
+-- | Read a Parquet file using explicit read options.
 readParquetWithOpts :: ParquetReadOptions -> FilePath -> IO DataFrame
 readParquetWithOpts = _readParquetWithOpts Nothing
 
 -- | Internal entry point used by tests to force non-seekable mode.
 _readParquetWithOpts ::
     ForceNonSeekable -> ParquetReadOptions -> FilePath -> IO DataFrame
-_readParquetWithOpts extraConfig opts path =
-    withFileBufferedOrSeekable extraConfig path ReadMode $ \file ->
-        runReaderIO (parseParquetWithOpts opts) file
+_readParquetWithOpts extraConfig opts path = do
+    metadata <-
+        withFileBufferedOrSeekable
+            extraConfig
+            path
+            ReadMode
+            (runReaderIO parseFileMetadata)
+    readParquetWithMetadata extraConfig metadata opts path
 
-{- | Read Parquet files from a directory or glob path.
+-- | Read a Parquet file whose footer has already been parsed.
+readParquetWithMetadata ::
+    ForceNonSeekable ->
+    FileMetadata ->
+    ParquetReadOptions ->
+    FilePath ->
+    IO DataFrame
+readParquetWithMetadata extraConfig metadata opts path = do
+    caps <- getNumCapabilities
+    let nRowGroups = length (unField metadata.row_groups)
+    if parallel opts && extraConfig /= Just True && caps > 1 && nRowGroups > 1
+        then decodeRowGroupsParallel caps opts path metadata
+        else withFileBufferedOrSeekable extraConfig path ReadMode $ \file ->
+            runReaderIO (parseParquetWithOpts opts) file
 
-This is equivalent to calling 'readParquetFilesWithOpts' with 'defaultParquetReadOptions'.
--}
-readParquetFiles :: FilePath -> IO DataFrame
-readParquetFiles = readParquetFilesWithOpts defaultParquetReadOptions
+-- | Decode row groups in parallel, one task per (row group, column).
+decodeRowGroupsParallel ::
+    Int -> ParquetReadOptions -> FilePath -> FileMetadata -> IO DataFrame
+decodeRowGroupsParallel caps opts0 path metadata = do
+    let plan = planColumns opts0 metadata
+        descs = Vector.fromList (planDescs plan)
+        allRowGroupRows =
+            map (fromIntegral . unField . rg_num_rows) (unField metadata.row_groups)
+        allOffsets = scanl (+) 0 allRowGroupRows
+        -- Row groups whose statistics rule out the predicate are skipped.
+        statsGroups =
+            [ g
+            | (g, rg) <- zip [0 ..] (unField metadata.row_groups)
+            , maybe True (rowGroupMayMatch metadata rg) (predicate opts0)
+            ]
+        -- A row range only touches overlapping groups, then is rebased.
+        (keptGroups, opts) = case rowRange opts0 of
+            Nothing -> (statsGroups, opts0)
+            Just (s, e) ->
+                let groups =
+                        [ g
+                        | (g, off, n) <- zip3 [0 ..] allOffsets allRowGroupRows
+                        , off < e
+                        , off + n > s
+                        ]
+                    base = case groups of
+                        [] -> 0
+                        (g : _) -> allOffsets !! g
+                 in (groups, opts0{rowRange = Just (s - base, e - base)})
+        chunks =
+            Vector.fromList
+                [Vector.fromList [cs !! g | g <- keptGroups] | cs <- planChunks plan]
+        rowGroupRows = Vector.fromList [allRowGroupRows !! g | g <- keptGroups]
+        rowOffsets = Vector.prescanl' (+) 0 rowGroupRows
+        totalRows = Vector.sum rowGroupRows
+        nCols = Vector.length descs
+        nRg = Vector.length rowGroupRows
+        nTasks = nCols * nRg
+        taskChunk t = let (r, c) = t `divMod` nCols in chunks Vector.! c Vector.! r
+    sinks <- Vector.mapM (newSink (widenIntegers opts) totalRows) descs
+    results <- MVector.new nTasks
+    counter <- newIORef (0 :: Int)
+    -- Each worker owns a handle, so chunk reads run in parallel too.
+    let worker = withFileBufferedOrSeekable Nothing path ReadMode $ \file ->
+            let loop = do
+                    t <- atomicModifyIORef' counter (\n -> (n + 1, n))
+                    when (t < nTasks) $ do
+                        let (r, c) = t `divMod` nCols
+                            desc = descs Vector.! c
+                            chunk = taskChunk t
+                            (base, len) = chunkRange chunk
+                        bytes <- runReaderIO (readBytes (Range base len)) file
+                        let buffer = ChunkBuffer base bytes
+                            into ::
+                                forall a.
+                                (VU.Unbox a) =>
+                                (DictVals -> Maybe (VU.Vector a)) ->
+                                (BS.ByteString -> Int -> a) ->
+                                Int ->
+                                VUM.IOVector a ->
+                                IO ()
+                            into pick plainAt width out =
+                                void $
+                                    runReaderIO
+                                        ( decodeRequiredNumericInto
+                                            pick
+                                            plainAt
+                                            width
+                                            desc
+                                            chunk
+                                            out
+                                            (rowOffsets Vector.! r)
+                                        )
+                                        buffer
+                        case sinks Vector.! c of
+                            SinkInt out width ->
+                                if width == 4
+                                    then into (fmap (VU.map fromIntegral) . dictInt32) plainIntFrom32 4 out
+                                    else into (fmap (VU.map fromIntegral) . dictInt64) plainIntFrom64 8 out
+                            SinkInt32 out -> into dictInt32 plainInt32 4 out
+                            SinkInt64 out -> into dictInt64 plainInt64 8 out
+                            SinkFloat out -> into dictFloat plainFloat 4 out
+                            SinkDouble out -> into dictDouble plainDouble 8 out
+                            SinkRows -> do
+                                col <-
+                                    runReaderIO
+                                        (parseColumnChunks (rowGroupRows Vector.! r) [chunk] desc)
+                                        buffer
+                                MVector.write results (c * nRg + r) col
+                        loop
+             in loop
+    forkJoin_ (replicate (max 1 (min caps nTasks)) worker)
+    cols <- forM [0 .. nCols - 1] $ \c -> case sinks Vector.! c of
+        SinkInt out _ -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
+        SinkInt32 out -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
+        SinkInt64 out -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
+        SinkFloat out -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
+        SinkDouble out -> DI.UnboxedColumn Nothing <$> VU.unsafeFreeze out
+        SinkRows ->
+            concatColumnsIO
+                =<< forM [0 .. nRg - 1] (\r -> MVector.read results (c * nRg + r))
+    pure (assembleDataFrame opts totalRows metadata plan cols)
 
-{- | Read multiple Parquet files (directory or glob) using explicit options.
+-- | Where a column's decoded values go.
+data ColumnSink
+    = -- | Widened integers; the width is the source's bytes per value.
+      SinkInt !(VUM.IOVector Int) !Int
+    | SinkInt32 !(VUM.IOVector Int32)
+    | SinkInt64 !(VUM.IOVector Int64)
+    | SinkFloat !(VUM.IOVector Float)
+    | SinkDouble !(VUM.IOVector Double)
+    | SinkRows
 
-If @path@ is a directory, all non-directory entries are read.
-If @path@ is a glob, matching files are read.
+newSink :: Bool -> Int -> ColumnDescription -> IO ColumnSink
+newSink widen totalRows description
+    | description.maxRepetitionLevel /= 0 || description.maxDefinitionLevel /= 0 =
+        pure SinkRows
+    | otherwise = case description.colElementType of
+        Just (INT32 _)
+            | widen -> (`SinkInt` 4) <$> VUM.unsafeNew totalRows
+            | otherwise -> SinkInt32 <$> VUM.unsafeNew totalRows
+        Just (INT64 _)
+            | widen -> (`SinkInt` 8) <$> VUM.unsafeNew totalRows
+            | otherwise -> SinkInt64 <$> VUM.unsafeNew totalRows
+        Just (FLOAT _) -> SinkFloat <$> VUM.unsafeNew totalRows
+        Just (DOUBLE _) -> SinkDouble <$> VUM.unsafeNew totalRows
+        _ -> pure SinkRows
 
-For multi-file reads, @rowRange@ is applied once after concatenation (global range semantics).
+plainIntFrom32 :: BS.ByteString -> Int -> Int
+plainIntFrom32 bs o = fromIntegral (fromIntegral (word32At bs o) :: Int32)
+{-# INLINE plainIntFrom32 #-}
 
-==== __Example__
-@
-ghci> D.readParquetFilesWithOpts
-ghci|   (D.defaultParquetReadOptions{D.selectedColumns = Just ["id"], D.rowRange = Just (0, 5)})
-ghci|   "./tests/data/alltypes_plain*.parquet"
-@
--}
+plainIntFrom64 :: BS.ByteString -> Int -> Int
+plainIntFrom64 bs o = fromIntegral (word64At bs o)
+{-# INLINE plainIntFrom64 #-}
+
+plainInt32 :: BS.ByteString -> Int -> Int32
+plainInt32 bs o = fromIntegral (word32At bs o)
+{-# INLINE plainInt32 #-}
+
+plainInt64 :: BS.ByteString -> Int -> Int64
+plainInt64 bs o = fromIntegral (word64At bs o)
+{-# INLINE plainInt64 #-}
+
+plainFloat :: BS.ByteString -> Int -> Float
+plainFloat bs o = castWord32ToFloat (word32At bs o)
+{-# INLINE plainFloat #-}
+
+plainDouble :: BS.ByteString -> Int -> Double
+plainDouble bs o = castWord64ToDouble (word64At bs o)
+{-# INLINE plainDouble #-}
+
+-- | Whether a row group can hold a row satisfying the predicate.
+rowGroupMayMatch :: FileMetadata -> RowGroup -> Expr Bool -> Bool
+rowGroupMayMatch metadata rg = go
+  where
+    allNames = getColumnNames (drop 1 (unField metadata.schema))
+    chunks = unField (rg_columns rg)
+    go :: Expr Bool -> Bool
+    go (Binary op l r)
+        | binaryName op == "and" = case (l, r) of
+            (l' :: Expr lt, r' :: Expr rt)
+                | Just Refl <- testEquality (typeRep @lt) (typeRep @Bool)
+                , Just Refl <- testEquality (typeRep @rt) (typeRep @Bool) ->
+                    go l' && go r'
+            _ -> True
+        | otherwise = case (l, r) of
+            (Col name :: Expr lt, Lit (v :: rt))
+                | Just Refl <- testEquality (typeRep @lt) (typeRep @Int)
+                , Just Refl <- testEquality (typeRep @rt) (typeRep @Int) ->
+                    decide name (binaryName op) v
+            (Lit (v :: lt), Col name :: Expr rt)
+                | Just Refl <- testEquality (typeRep @lt) (typeRep @Int)
+                , Just Refl <- testEquality (typeRep @rt) (typeRep @Int) ->
+                    decide name (flipped (binaryName op)) v
+            _ -> True
+    go _ = True
+    flipped :: T.Text -> T.Text
+    flipped n = case n of
+        "lt" -> "gt"
+        "gt" -> "lt"
+        "leq" -> "geq"
+        "geq" -> "leq"
+        other -> other
+    -- @column <cmp> v@ can hold for some row in [lo, hi].
+    decide :: T.Text -> T.Text -> Int -> Bool
+    decide name cmp v = case columnRange name of
+        Nothing -> True
+        Just (lo, hi) -> case cmp of
+            "eq" -> lo <= v && v <= hi
+            "neq" -> not (lo == v && hi == v)
+            "lt" -> lo < v
+            "leq" -> lo <= v
+            "gt" -> hi > v
+            "geq" -> hi >= v
+            _ -> True
+    columnRange :: T.Text -> Maybe (Int, Int)
+    columnRange name = do
+        i <- L.findIndex (columnMatches allNames name) allNames
+        cc <- chunks `atMay` i
+        meta <- unField (cc_meta_data cc)
+        stats <- unField (cmd_statistics meta)
+        let decodeInt bs = case unField (cmd_type meta) of
+                INT32 _
+                    | BS.length bs == 4 ->
+                        Just (fromIntegral (fromIntegral (word32At bs 0) :: Int32))
+                INT64 _ | BS.length bs == 8 -> Just (fromIntegral (word64At bs 0))
+                _ -> Nothing
+        lo <-
+            decodeInt
+                =<< (unField (stats_min_value stats) `orElse` unField (stats_min stats))
+        hi <-
+            decodeInt
+                =<< (unField (stats_max_value stats) `orElse` unField (stats_max stats))
+        pure (lo, hi)
+    orElse (Just x) _ = Just x
+    orElse Nothing y = y
+    atMay xs i = if i >= 0 && i < length xs then Just (xs !! i) else Nothing
+
+-- | File offset and byte length of a column chunk (dictionary page included).
+chunkRange :: ColumnChunk -> (Integer, Int)
+chunkRange columnChunk =
+    let meta = fromJust . unField $ columnChunk.cc_meta_data
+        dataOffset = fromIntegral . unField $ meta.cmd_data_page_offset
+        dictOffset = fromIntegral <$> unField meta.cmd_dictionary_page_offset
+     in ( fromMaybe dataOffset dictOffset
+        , fromIntegral . unField $ meta.cmd_total_compressed_size
+        )
+
+-- | The columns a read decodes, after projection, in schema order.
+data ColumnPlan = ColumnPlan
+    { planNames :: [T.Text]
+    , planDescs :: [ColumnDescription]
+    , planChunks :: [[ColumnChunk]]
+    -- ^ Per column, one chunk per row group.
+    }
+
+-- | Read Parquet files from a directory or glob path.
+readParquetFiles :: FilePath -> IO DataFrame
+readParquetFiles = readParquetFilesWithOpts defaultParquetReadOptions
+
+-- | Read multiple Parquet files (directory or glob) using options.
 readParquetFilesWithOpts :: ParquetReadOptions -> FilePath -> IO DataFrame
 readParquetFilesWithOpts opts path = do
     isDir <- doesDirectoryExist path
@@ -288,12 +520,7 @@
             dfs <- mapM (readParquetWithOpts optsWithoutRowRange) files
             pure (applyRowRange opts (mconcat dfs))
 
--- Core parsing pipeline ---------------------------------------------------
-
-{- | Parse a Parquet file via the 'RandomAccess' handle, applying all
-read options. This is the central parsing entry point used by
-'_readParquetWithOpts'.
--}
+-- | Parse a Parquet file via the 'RandomAccess' handle.
 parseParquetWithOpts ::
     (RandomAccess m, MonadIO m) =>
     ParquetReadOptions ->
@@ -304,92 +531,120 @@
 {-# INLINEABLE parseParquetWithOpts #-}
 parseParquetWithOpts opts = do
     metadata <- parseFileMetadata
+    let plan = planColumns opts metadata
+        vectorLength = fileRowCount metadata
+    rawCols <-
+        zipWithM (parseColumnChunks vectorLength) (planChunks plan) (planDescs plan)
+    return (assembleDataFrame opts vectorLength metadata plan rawCols)
 
+-- | Row count from the footer, falling back to the sum of row-group counts.
+fileRowCount :: FileMetadata -> Int
+fileRowCount metadata =
+    let topLevelRows = fromIntegral . unField $ metadata.num_rows :: Int
+        rgRows =
+            sum $ map (fromIntegral . unField . rg_num_rows) (unField metadata.row_groups) ::
+                Int
+     in if topLevelRows > 0 then topLevelRows else rgRows
+
+columnMatches :: [T.Text] -> T.Text -> T.Text -> Bool
+columnMatches allNames requested name =
+    name == requested
+        || (leafOf name == requested && requested `notElem` allNames)
+
+-- | The last dot-separated segment of a column path.
+leafOf :: T.Text -> T.Text
+leafOf = last . T.splitOn "."
+
+-- | Validate the requested columns against the schema and pick what to decode.
+planColumns :: ParquetReadOptions -> FileMetadata -> ColumnPlan
+planColumns opts metadata =
     let schemaElems = unField metadata.schema
         allNames = getColumnNames (drop 1 schemaElems)
-        leafNames = L.nub (map (last . T.splitOn ".") allNames)
+        knownNames = L.nub (allNames ++ map leafOf allNames)
         predicateColumns = maybe [] (L.nub . getColumns) (predicate opts)
         selectedColumnsForRead = case selectedColumns opts of
             Nothing -> Nothing
             Just selected -> Just (L.nub (selected ++ predicateColumns))
 
-    -- TODO: When rowRange is set, compute cumulative row offsets from
-    -- rg_num_rows in each RowGroup and skip any group whose row interval does
-    -- not overlap the requested range, avoiding all decoding for those groups.
+        -- TODO: With rowRange set, skip row groups the range does not overlap.
 
-    -- TODO: When predicate is set, inspect cmd_statistics min/max values for
-    -- predicate-referenced columns in each RowGroup and skip groups where
-    -- statistics prove the predicate cannot be satisfied.
+        -- TODO: Skip row groups whose statistics rule out the predicate.
 
-    -- Validate selected columns
-    case selectedColumnsForRead of
-        Nothing -> pure ()
-        Just requested ->
-            let missing = requested L.\\ leafNames
-             in unless (L.null missing) $
-                    liftIO $
-                        throw
-                            ( ColumnsNotFoundException
-                                missing
-                                "readParquetWithOpts"
-                                leafNames
-                            )
+        -- Validate selected columns
+        validated = case selectedColumnsForRead of
+            Nothing -> ()
+            Just requested ->
+                let missing = [r | r <- requested, not (any (columnMatches allNames r) allNames)]
+                 in if L.null missing
+                        then ()
+                        else
+                            throw
+                                ( ColumnsNotFoundException
+                                    missing
+                                    "readParquetWithOpts"
+                                    knownNames
+                                )
 
-    let descriptions = generateColumnDescriptions schemaElems
+        descriptions = generateColumnDescriptions schemaElems
         chunks = columnChunksForAll metadata
         nCols = length chunks
         nDescs = length descriptions
 
-    unless (nCols == nDescs) $
-        error $
-            "Column count mismatch: got "
-                <> show nCols
-                <> " columns but schema implied "
-                <> show nDescs
-                <> " columns"
-
-    -- Some files omit the top-level num_rows field; fall back to summing row-group counts.
-    let topLevelRows = fromIntegral . unField $ metadata.num_rows :: Int
-        rgRows =
-            sum $ map (fromIntegral . unField . rg_num_rows) (unField metadata.row_groups) ::
-                Int
-        vectorLength = if topLevelRows > 0 then topLevelRows else rgRows
+        counted =
+            if nCols == nDescs
+                then ()
+                else
+                    error $
+                        "Column count mismatch: got "
+                            <> show nCols
+                            <> " columns but schema implied "
+                            <> show nDescs
+                            <> " columns"
 
-    -- Column-projection pushdown: decode only the columns needed for the
-    -- requested output plus any predicate, instead of decoding every column
-    -- and dropping the unwanted ones afterward. A 'Nothing' selection keeps
-    -- all columns, so full reads are unchanged.
-    let keep name = case selectedColumnsForRead of
+        -- Decode only the columns needed for the output plus any predicate.
+        keep name = case selectedColumnsForRead of
             Nothing -> True
-            Just req -> last (T.splitOn "." name) `elem` req
+            Just req -> any (\r -> columnMatches allNames r name) req
         kept = filter (\(n, _, _) -> keep n) (zip3 allNames chunks descriptions)
-        keptNames = [n | (n, _, _) <- kept]
-        keptChunks = [c | (_, c, _) <- kept]
-        keptDescs = [d | (_, _, d) <- kept]
-
-    rawCols <- zipWithM (parseColumnChunks vectorLength) keptChunks keptDescs
+     in validated `seq`
+            counted `seq`
+                ColumnPlan
+                    { planNames = [n | (n, _, _) <- kept]
+                    , planDescs = [d | (_, _, d) <- kept]
+                    , planChunks = [c | (_, c, _) <- kept]
+                    }
 
+-- | Apply logical types and read options to the decoded columns.
+assembleDataFrame ::
+    ParquetReadOptions -> Int -> FileMetadata -> ColumnPlan -> [Column] -> DataFrame
+assembleDataFrame opts totalRows metadata plan rawCols =
     let hints = nativeTypeHints metadata
+        keptNames = planNames plan
+        widen col
+            | not (widenIntegers opts) = col
+            | otherwise = case col of
+                DI.UnboxedColumn Nothing (v :: VU.Vector t)
+                    | Just Refl <- testEquality (typeRep @t) (typeRep @Int32) ->
+                        DI.UnboxedColumn Nothing (VU.map (fromIntegral @Int32 @Int) v)
+                    | Just Refl <- testEquality (typeRep @t) (typeRep @Int64) ->
+                        DI.UnboxedColumn Nothing (VU.map (fromIntegral @Int64 @Int) v)
+                _ -> col
         finalCols =
             zipWith
                 (restoreNativeType hints)
                 keptNames
-                (zipWith applyDescLogicalType keptDescs rawCols)
+                (map widen (zipWith applyDescLogicalType (planDescs plan) rawCols))
         indices = Map.fromList $ zip keptNames [0 ..]
-        dimensions = (vectorLength, length finalCols)
-
-    let df =
+        dimensions = (totalRows, length finalCols)
+        df =
             DataFrame
                 (Vector.fromListN (length finalCols) finalCols)
                 indices
                 dimensions
                 Map.empty
-
-    return (applyReadOptions opts df)
+     in applyReadOptions opts df
 
-{- | Parse the file-level Thrift metadata from the Parquet file footer.
-Validates the trailing 4-byte magic marker (\"PAR1\") before decoding.
--}
+-- | Parse the file-level Thrift metadata from the Parquet footer.
 parseFileMetadata :: (RandomAccess m) => m FileMetadata
 parseFileMetadata = do
     footerBytes <- readSuffix 8
@@ -438,6 +693,12 @@
     ColumnDescription ->
     ReaderIO FileBufferedOrSeekable Column
     #-}
+{-# SPECIALIZE parseColumnChunks ::
+    Int ->
+    [ColumnChunk] ->
+    ColumnDescription ->
+    ReaderIO ChunkBuffer Column
+    #-}
 {-# INLINEABLE parseColumnChunks #-}
 parseColumnChunks totalRows chunks description
     | description.maxRepetitionLevel == 0 && description.maxDefinitionLevel == 0 =
@@ -478,16 +739,11 @@
     go decoder =
         foldNonNullable totalRows (foldColumnPagesM description decoder chunks)
 
-    -- Decode a non-nullable BYTE_ARRAY (UTF-8) column straight into a single
-    -- shared byte buffer + offsets ('PackedText'), instead of a boxed vector
-    -- of per-row 'Text'. Each page's decoded 'Text' values (which share the
-    -- chunk dictionary for dictionary-encoded pages) are appended by memcpy
-    -- into one builder across all pages/chunks, then frozen once. This is the
-    -- same representation the fast CSV reader uses and matches Arrow's string
-    -- layout: no retained per-row 'Text' headers, no eager UTF-8 validation.
+    -- Decode non-nullable UTF-8 BYTE_ARRAY straight into one 'PackedText'.
     goPackedText :: m Column
     goPackedText = do
-        builder <- liftIO $ stToIO (newTextBuilder totalRows (totalRows * 8))
+        builder <-
+            liftIO $ stToIO (newTextBuilder totalRows (textByteHint totalRows chunks))
         _ <-
             foldColumnDataPagesM
                 description
@@ -507,6 +763,16 @@
     unboxedGo decoder =
         foldNonNullableUnboxed totalRows (foldColumnPagesM description decoder chunks)
 
+-- | Initial byte capacity for a text column.
+textByteHint :: Int -> [ColumnChunk] -> Int
+textByteHint totalRows chunks =
+    max (totalRows * 8) $
+        sum
+            [ fromIntegral (unField (cmd_total_uncompressed_size meta))
+            | cc <- chunks
+            , Just meta <- [unField (cc_meta_data cc)]
+            ]
+
 -- | Decode an optional (nullable) column.
 {-# INLINEABLE getNullableColumn #-}
 getNullableColumn ::
@@ -541,13 +807,11 @@
     go decoder =
         foldNullable maxDef totalRows (foldColumnPagesM description decoder chunks)
 
-    -- Nullable BYTE_ARRAY (UTF-8): decode straight into a 'PackedText' (shared
-    -- byte buffer + offsets + validity bitmap) via the text builder, walking
-    -- def-levels to interleave nulls. Avoids the boxed @Vector Text@ the
-    -- generic 'foldNullable' path would build.
+    -- Nullable UTF-8 BYTE_ARRAY: decode straight into a 'PackedText'.
     goPackedTextNullable :: m Column
     goPackedTextNullable = do
-        builder <- liftIO $ stToIO (newTextBuilder totalRows (totalRows * 8))
+        builder <-
+            liftIO $ stToIO (newTextBuilder totalRows (textByteHint totalRows chunks))
         _ <-
             foldColumnDataPagesM
                 description
@@ -622,8 +886,6 @@
     unboxedGo decoder =
         foldRepeatedUnboxed maxRep maxDef (foldColumnPagesM description decoder chunks)
 
--- Options application -----------------------------------------------------
-
 applyRowRange :: ParquetReadOptions -> DataFrame -> DataFrame
 applyRowRange opts df =
     maybe df (`DS.range` df) (rowRange opts)
@@ -648,11 +910,7 @@
         . applySelectedColumns opts
         . applyPredicate opts
 
--- Logical type conversion -------------------------------------------------
-
-{- | Apply a column-description's logical type annotation to convert raw
-decoded values (e.g. millisecond integers → 'UTCTime').
--}
+-- | Apply a column's logical type annotation to its decoded values.
 applyDescLogicalType :: ColumnDescription -> DI.Column -> DI.Column
 applyDescLogicalType desc = applyLogicalType (colLogicalType desc)
 
@@ -660,10 +918,7 @@
 applyLogicalType (Just (LT_TIMESTAMP f)) col =
     let ts = unField f
         unit = unField ts.timestamp_unit
-        -- (ticks per second, picoseconds per tick) for each unit. Convert at
-        -- native precision: a millisecond grid keeps ms, a nanosecond grid
-        -- keeps ns. (The old code multiplied by @1_000_000 `div` divisor@,
-        -- which truncated NANOS to 0 and collapsed every value to the epoch.)
+        -- Convert at native precision (ticks per second, ps per tick).
         conv = case unit of
             MILLIS _ -> epochToUTCTime 1_000 1_000_000_000
             MICROS _ -> epochToUTCTime 1_000_000 1_000_000
@@ -674,18 +929,14 @@
         scale = unField dt.decimal_scale
         precision = unField dt.decimal_precision
      in if precision <= 9
-            then case DI.toVector @Int32 @VU.Vector col of
-                Right xs ->
-                    DI.fromUnboxedVector $
-                        VU.map (\raw -> fromIntegral @Int32 @Double raw / 10 ^ scale) xs
-                Left _ -> col
+            then
+                fromRight col $
+                    DI.mapColumn (\raw -> fromIntegral @Int32 @Double raw / 10 ^ scale) col
             else
                 if precision <= 18
-                    then case DI.toVector @Int64 @VU.Vector col of
-                        Right xs ->
-                            DI.fromUnboxedVector $
-                                VU.map (\raw -> fromIntegral @Int64 @Double raw / 10 ^ scale) xs
-                        Left _ -> col
+                    then
+                        fromRight col $
+                            DI.mapColumn (\raw -> fromIntegral @Int64 @Double raw / 10 ^ scale) col
                     else col
 applyLogicalType _ col = col
 
@@ -725,28 +976,23 @@
         ]
 
 restoreNativeType :: Schema -> T.Text -> DI.Column -> DI.Column
-restoreNativeType hints name col = case Map.lookup leaf (elements hints) of
+restoreNativeType hints name col = case lookupHint of
     Just ty
         | stampedAs @Int ty -> narrow (fromIntegral @Int64 @Int)
         | stampedAs @Integer ty -> narrow (fromIntegral @Int64 @Integer)
     _ -> col
   where
-    leaf = last (T.splitOn "." name)
+    lookupHint =
+        case Map.lookup name (elements hints) of
+            Just ty -> Just ty
+            Nothing -> Map.lookup (leafOf name) (elements hints)
     stampedAs ::
         forall a. (Columnable a, Read a, Columnable (Maybe a)) => SchemaType -> Bool
     stampedAs ty = ty == schemaType @a || ty == schemaType @(Maybe a)
     narrow :: (Columnable a) => (Int64 -> a) -> DI.Column
     narrow f = fromRight col (DI.mapColumn f col)
 
-{- | Convert an epoch timestamp expressed as @ticksPerSecond@ ticks/second
-(each tick = @psPerTick@ picoseconds) to 'UTCTime', at full precision.
-
-Splits into days + within-day picoseconds with integer 'divMod' (which floors,
-so the split is correct for pre-epoch negative values too), and never forms
-picoseconds-since-epoch — that would overflow 'Int64' for modern dates — only
-the bounded within-day picosecond count. 40587 is the Modified Julian Day of
-the Unix epoch (1970-01-01).
--}
+-- | Convert an epoch timestamp with the given tick rate to 'UTCTime'.
 epochToUTCTime :: Int64 -> Integer -> Int64 -> UTCTime
 epochToUTCTime ticksPerSecond psPerTick v =
     let (s, subTicks) = v `divMod` ticksPerSecond
diff --git a/src/DataFrame/IO/Parquet/Binary.hs b/src/DataFrame/IO/Parquet/Binary.hs
--- a/src/DataFrame/IO/Parquet/Binary.hs
+++ b/src/DataFrame/IO/Parquet/Binary.hs
@@ -18,12 +18,7 @@
 readUVarInt :: BS.ByteString -> (Word64, BS.ByteString)
 readUVarInt xs = loop xs 0 0 0
   where
-    {-
-    Each input byte contributes:
-    - lower 7 payload bits
-    - The high bit (0x80) is the continuation flag: 1 = more bytes follow, 0 = last byte
-    Why the magic number 10: For a 64‑bit integer we need at most ceil(64 / 7) = 10 bytes
-    -}
+    -- The high bit flags continuation; 10 bytes cover a 64-bit integer.
     loop :: BS.ByteString -> Word64 -> Int -> Int -> (Word64, BS.ByteString)
     loop bs result _ 10 = (result, bs)
     loop xs' result shiftAmt i = case BS.uncons xs' of
@@ -113,9 +108,7 @@
         (fromIntegral size)
         ((`readSingleByte` buf) . fromIntegral)
 
-{- | Allocate a fixed-size buffer, repeat the action on each index.
-Fill it into the buffer to get a ByteString.
--}
+-- | Allocate a fixed-size buffer and fill it by repeating an action.
 fillByteStringByWord8 :: Int -> (Int -> IO Word8) -> IO BS.ByteString
 fillByteStringByWord8 size getByte = do
     bracketOnError
diff --git a/src/DataFrame/IO/Parquet/Dictionary.hs b/src/DataFrame/IO/Parquet/Dictionary.hs
--- a/src/DataFrame/IO/Parquet/Dictionary.hs
+++ b/src/DataFrame/IO/Parquet/Dictionary.hs
@@ -1,6 +1,17 @@
 {-# LANGUAGE BangPatterns #-}
 
-module DataFrame.IO.Parquet.Dictionary (DictVals (..), readDictVals, decodeRLEBitPackedHybrid) where
+module DataFrame.IO.Parquet.Dictionary (
+    DictVals (..),
+    readDictVals,
+    dictText,
+    dictInt32,
+    dictInt64,
+    dictFloat,
+    dictDouble,
+    word32At,
+    word64At,
+    decodeRLEBitPackedHybrid,
+) where
 
 import Data.Bits
 import qualified Data.ByteString as BS
@@ -10,109 +21,101 @@
 import Data.Text.Encoding
 import Data.Time (UTCTime)
 import qualified Data.Vector as V
+import qualified Data.Vector.Unboxed as VU
 import Data.Word
 import DataFrame.IO.Parquet.Binary (readUVarInt)
 import DataFrame.IO.Parquet.Thrift (ThriftType (..))
 import DataFrame.IO.Parquet.Time (int96ToUTCTime)
-import DataFrame.Internal.Binary (
-    littleEndianInt32,
-    littleEndianWord32,
-    littleEndianWord64,
- )
+import DataFrame.Internal.Binary (littleEndianWord32)
 import GHC.Float
 
+-- | A decoded dictionary page with unboxed numeric entries.
 data DictVals
-    = DBool (V.Vector Bool)
-    | DInt32 (V.Vector Int32)
-    | DInt64 (V.Vector Int64)
-    | DInt96 (V.Vector UTCTime)
-    | DFloat (V.Vector Float)
-    | DDouble (V.Vector Double)
-    | DText (V.Vector T.Text)
+    = DBool !(VU.Vector Bool)
+    | DInt32 !(VU.Vector Int32)
+    | DInt64 !(VU.Vector Int64)
+    | DInt96 !(V.Vector UTCTime)
+    | DFloat !(VU.Vector Float)
+    | DDouble !(VU.Vector Double)
+    | DText !(VU.Vector Int) !(VU.Vector Int) !BS.ByteString
     deriving (Show, Eq)
 
-{- | Decode the values from a dictionary page.
-
-The @numVals@ argument is the entry count declared in the dictionary page
-header.  It is used to limit BOOLEAN decoding (1-bit-per-value encoding has
-no natural delimiter).
-
-The @typeLength@ argument is only meaningful for FIXED_LEN_BYTE_ARRAY: it is
-the byte-width of each individual dictionary entry, NOT the total number of
-entries.  Passing @numVals@ here (the old behaviour) would cause it to be
-misread as an element size, yielding a dictionary that is far too small.
--}
+-- | Decode the values from a dictionary page.
 readDictVals :: ThriftType -> BS.ByteString -> Int32 -> Maybe Int32 -> DictVals
-readDictVals (BOOLEAN _) bs count _ = DBool (V.fromList (take (fromIntegral count) $ readPageBool bs))
-readDictVals (INT32 _) bs _ _ = DInt32 (V.fromList (readPageInt32 bs))
-readDictVals (INT64 _) bs _ _ = DInt64 (V.fromList (readPageInt64 bs))
-readDictVals (INT96 _) bs _ _ = DInt96 (V.fromList (readPageInt96Times bs))
-readDictVals (FLOAT _) bs _ _ = DFloat (V.fromList (readPageFloat bs))
-readDictVals (DOUBLE _) bs _ _ = DDouble (V.fromList (readPageWord64 bs))
-readDictVals (BYTE_ARRAY _) bs _ _ = DText (V.fromList (readPageBytes bs))
+readDictVals (BOOLEAN _) bs count _ =
+    let n = min (fromIntegral count) (8 * BS.length bs)
+     in DBool $ VU.generate n $ \i ->
+            (BSU.unsafeIndex bs (i `shiftR` 3) `shiftR` (i .&. 7)) .&. 1 == 1
+readDictVals (INT32 _) bs _ _ =
+    DInt32 $ VU.generate (BS.length bs `div` 4) $ \i -> fromIntegral (word32At bs (4 * i))
+readDictVals (INT64 _) bs _ _ =
+    DInt64 $ VU.generate (BS.length bs `div` 8) $ \i -> fromIntegral (word64At bs (8 * i))
+readDictVals (INT96 _) bs _ _ =
+    DInt96 $ V.generate (BS.length bs `div` 12) $ \i ->
+        int96ToUTCTime (BSU.unsafeTake 12 (BSU.unsafeDrop (12 * i) bs))
+readDictVals (FLOAT _) bs _ _ =
+    DFloat $ VU.generate (BS.length bs `div` 4) $ \i -> castWord32ToFloat (word32At bs (4 * i))
+readDictVals (DOUBLE _) bs _ _ =
+    DDouble $ VU.generate (BS.length bs `div` 8) $ \i -> castWord64ToDouble (word64At bs (8 * i))
+readDictVals (BYTE_ARRAY _) bs _ _ = textDict bs
 readDictVals (FIXED_LEN_BYTE_ARRAY _) bs _ (Just len) =
-    DText (V.fromList (readPageFixedBytes bs (fromIntegral len)))
+    let w = fromIntegral len
+        n = if w <= 0 then 0 else BS.length bs `div` w
+     in DText (VU.generate n (* w)) (VU.replicate n w) bs
 readDictVals t _ _ _ = error $ "Unsupported dictionary type: " ++ show t
 
-readPageInt32 :: BS.ByteString -> [Int32]
-readPageInt32 xs
-    | BS.null xs = []
-    | otherwise = littleEndianInt32 (BS.take 4 xs) : readPageInt32 (BS.drop 4 xs)
-
-readPageWord64 :: BS.ByteString -> [Double]
-readPageWord64 xs
-    | BS.null xs = []
-    | otherwise =
-        castWord64ToDouble (littleEndianWord64 (BS.take 8 xs))
-            : readPageWord64 (BS.drop 8 xs)
+-- | Length-prefixed byte-array entries, in two passes.
+textDict :: BS.ByteString -> DictVals
+textDict bs = DText starts lens bs
+  where
+    !len = BS.length bs
+    count !off !n
+        | off + 4 > len = n
+        | otherwise = count (off + 4 + fromIntegral (word32At bs off)) (n + 1)
+    !n = count 0 0
+    positions = VU.unfoldrExactN n step 0
+    step off = let l = fromIntegral (word32At bs off) in ((off + 4, l), off + 4 + l)
+    (starts, lens) = VU.unzip positions
 
-readPageBytes :: BS.ByteString -> [T.Text]
-readPageBytes xs
-    | BS.null xs = []
-    | otherwise =
-        let lenBytes = fromIntegral (littleEndianInt32 $ BS.take 4 xs)
-            totalBytesRead = lenBytes + 4
-         in decodeUtf8Lenient (BS.take lenBytes (BS.drop 4 xs))
-                : readPageBytes (BS.drop totalBytesRead xs)
+-- | Dictionary entry @i@ as 'T.Text' (lenient decode, for the boxed paths).
+dictText :: DictVals -> Int -> T.Text
+dictText (DText starts lens bs) i =
+    decodeUtf8Lenient
+        ( BSU.unsafeTake
+            (VU.unsafeIndex lens i)
+            (BSU.unsafeDrop (VU.unsafeIndex starts i) bs)
+        )
+dictText d _ = error ("dictText: wrong dict type, got " ++ show d)
 
-readPageBool :: BS.ByteString -> [Bool]
-readPageBool bs =
-    concatMap (\b -> map (\i -> (b `shiftR` i) .&. 1 == 1) [0 .. 7]) (BS.unpack bs)
+dictInt32 :: DictVals -> Maybe (VU.Vector Int32)
+dictInt32 (DInt32 ds) = Just ds
+dictInt32 _ = Nothing
 
-readPageInt64 :: BS.ByteString -> [Int64]
-readPageInt64 xs
-    | BS.null xs = []
-    | otherwise =
-        fromIntegral (littleEndianWord64 (BS.take 8 xs)) : readPageInt64 (BS.drop 8 xs)
+dictInt64 :: DictVals -> Maybe (VU.Vector Int64)
+dictInt64 (DInt64 ds) = Just ds
+dictInt64 _ = Nothing
 
-readPageFloat :: BS.ByteString -> [Float]
-readPageFloat xs
-    | BS.null xs = []
-    | otherwise =
-        castWord32ToFloat (littleEndianWord32 (BS.take 4 xs))
-            : readPageFloat (BS.drop 4 xs)
+dictFloat :: DictVals -> Maybe (VU.Vector Float)
+dictFloat (DFloat ds) = Just ds
+dictFloat _ = Nothing
 
-readNInt96Times :: Int -> BS.ByteString -> ([UTCTime], BS.ByteString)
-readNInt96Times 0 bs = ([], bs)
-readNInt96Times k bs =
-    let timestamp96 = BS.take 12 bs
-        utcTime = int96ToUTCTime timestamp96
-        bs' = BS.drop 12 bs
-        (times, rest) = readNInt96Times (k - 1) bs'
-     in (utcTime : times, rest)
+dictDouble :: DictVals -> Maybe (VU.Vector Double)
+dictDouble (DDouble ds) = Just ds
+dictDouble _ = Nothing
 
-readPageInt96Times :: BS.ByteString -> [UTCTime]
-readPageInt96Times bs
-    | BS.null bs = []
-    | otherwise =
-        let (times, _) = readNInt96Times (BS.length bs `div` 12) bs
-         in times
+word32At :: BS.ByteString -> Int -> Word32
+word32At bs o =
+    fromIntegral (BSU.unsafeIndex bs o)
+        .|. (fromIntegral (BSU.unsafeIndex bs (o + 1)) `unsafeShiftL` 8)
+        .|. (fromIntegral (BSU.unsafeIndex bs (o + 2)) `unsafeShiftL` 16)
+        .|. (fromIntegral (BSU.unsafeIndex bs (o + 3)) `unsafeShiftL` 24)
+{-# INLINE word32At #-}
 
-readPageFixedBytes :: BS.ByteString -> Int -> [T.Text]
-readPageFixedBytes xs len
-    | BS.null xs = []
-    | otherwise =
-        decodeUtf8Lenient (BS.take len xs) : readPageFixedBytes (BS.drop len xs) len
+word64At :: BS.ByteString -> Int -> Word64
+word64At bs o =
+    fromIntegral (word32At bs o)
+        .|. (fromIntegral (word32At bs (o + 4)) `unsafeShiftL` 32)
+{-# INLINE word64At #-}
 
 unpackBitPacked :: Int -> Int -> BS.ByteString -> ([Word32], BS.ByteString)
 unpackBitPacked bw count bs
@@ -124,7 +127,7 @@
             rest = BS.drop totalBytes bs
          in (extractBits bw count chunk, rest)
 
--- | LSB-first bit accumulator: reads each byte once with no intermediate ByteString allocation.
+-- | LSB-first bit accumulator that reads each byte once.
 extractBits :: Int -> Int -> BS.ByteString -> [Word32]
 extractBits bw count bs = go 0 (0 :: Word64) 0 count
   where
@@ -145,9 +148,7 @@
     | bitWidth == 0 = ([0], bs)
     | BS.null bs = ([], bs)
     | otherwise =
-        -- readUVarInt is evaluated here, inside the guard that has already
-        -- confirmed bs is non-empty.  Keeping it in a where clause would cause
-        -- it to be forced before the BS.null guard under {-# LANGUAGE Strict #-}.
+        -- Evaluated inside the guard: a where clause would force it too early.
         let (hdr64, afterHdr) = readUVarInt bs
             isPacked = (hdr64 .&. 1) == 1
          in if isPacked
diff --git a/src/DataFrame/IO/Parquet/Encoding.hs b/src/DataFrame/IO/Parquet/Encoding.hs
--- a/src/DataFrame/IO/Parquet/Encoding.hs
+++ b/src/DataFrame/IO/Parquet/Encoding.hs
@@ -7,11 +7,13 @@
     bitWidthForMaxLevel,
     -- Vector-based RLE/bit-packed decoder (from new parser)
     decodeRLEBitPackedHybrid,
+    rleHybridFold,
     extractBitsInto,
     fillRun,
     decodeDictIndices,
 ) where
 
+import Control.Monad (when)
 import Control.Monad.ST (ST, runST)
 import Data.Bits
 import qualified Data.ByteString as BS
@@ -22,10 +24,6 @@
 import DataFrame.IO.Parquet.Binary (readUVarInt)
 import DataFrame.Internal.Binary (littleEndianWord32)
 
--- ---------------------------------------------------------------------------
--- Level-width helpers (used by Levels.hs)
--- ---------------------------------------------------------------------------
-
 ceilLog2 :: Int -> Int
 ceilLog2 x
     | x <= 1 = 0
@@ -34,10 +32,6 @@
 bitWidthForMaxLevel :: Int -> Int
 bitWidthForMaxLevel maxLevel = ceilLog2 (maxLevel + 1)
 
--- ---------------------------------------------------------------------------
--- Vector-based RLE / bit-packed hybrid decoder
--- ---------------------------------------------------------------------------
-
 decodeRLEBitPackedHybrid ::
     -- | Bit width per value (0 = all zeros, use 'VU.replicate')
     Int ->
@@ -66,8 +60,7 @@
                         let groups = fromIntegral (hdr64 `shiftR` 1) :: Int
                             totalVals = groups * 8
                             takeN = min (need - filled) totalVals
-                            -- Consume all the bytes for this group even if we
-                            -- only need a subset of the values.
+                            -- Consume the whole group even if we need less.
                             bytesN = (bw * totalVals + 7) `div` 8
                             (chunk, rest) = BS.splitAt bytesN afterHdr
                         extractBitsInto bw takeN chunk mv filled
@@ -87,10 +80,7 @@
 fillRun mv i end = VUM.set (VUM.unsafeSlice i (end - i) mv)
 {-# INLINE fillRun #-}
 
-{- | Write @count@ bit-width-@bw@ values from @bs@ into @mv@ starting at
-@offset@, reading the byte buffer with a single-pass LSB-first accumulator.
-No intermediate list or ByteString allocation.
--}
+-- | Write bit-packed values into a mutable vector in a single pass.
 extractBitsInto ::
     -- | Bit width
     Int ->
@@ -116,12 +106,7 @@
              in go (byteIdx + 1) (acc .|. (b `unsafeShiftL` accBits)) (accBits + 8) done
 {-# INLINE extractBitsInto #-}
 
-{- | Decode @need@ dictionary indices from a DATA_PAGE bit-width-prefixed
-stream (the first byte encodes the bit-width of all subsequent RLE\/bitpacked
-values).
-
-Returns the index vector (as 'Int') and the unconsumed bytes.
--}
+-- | Decode dictionary indices from a bit-width-prefixed DATA_PAGE stream.
 decodeDictIndices :: Int -> BS.ByteString -> (VU.Vector Int, BS.ByteString)
 decodeDictIndices need bs = case BS.uncons bs of
     Nothing -> error "decodeDictIndices: empty stream"
@@ -130,3 +115,55 @@
             (raw, rest1) = decodeRLEBitPackedHybrid bw need rest0
          in (VU.map fromIntegral raw, rest1)
 {-# INLINE decodeDictIndices #-}
+
+-- | Stream RLE\/bit-packed hybrid values to a sink.
+rleHybridFold ::
+    Int -> Int -> BS.ByteString -> (Int -> Word32 -> ST s ()) -> ST s ()
+rleHybridFold bw need bs sink
+    | bw == 0 = zeros 0
+    | otherwise = go 0 bs
+  where
+    !mask = if bw == 32 then maxBound else (1 `unsafeShiftL` bw) - 1 :: Word32
+    zeros !i = when (i < need) (sink i 0 >> zeros (i + 1))
+    go !filled !buf
+        | filled >= need = pure ()
+        | BS.null buf = pure ()
+        | otherwise =
+            let (hdr64, afterHdr) = readUVarInt buf
+                isPacked = (hdr64 .&. 1) == 1
+             in if isPacked
+                    then do
+                        let groups = fromIntegral (hdr64 `shiftR` 1) :: Int
+                            totalVals = groups * 8
+                            takeN = min (need - filled) totalVals
+                            bytesN = (bw * totalVals + 7) `div` 8
+                            (chunk, rest) = BS.splitAt bytesN afterHdr
+                        extractBitsFold bw takeN chunk (\j w -> sink (filled + j) w)
+                        go (filled + takeN) rest
+                    else do
+                        let runLen = fromIntegral (hdr64 `shiftR` 1) :: Int
+                            nbytes = (bw + 7) `div` 8
+                            val = littleEndianWord32 (BS.take 4 afterHdr) .&. mask
+                            takeN = min (need - filled) runLen
+                            run !j = when (j < takeN) (sink (filled + j) val >> run (j + 1))
+                        run 0
+                        go (filled + takeN) (BS.drop nbytes afterHdr)
+{-# INLINE rleHybridFold #-}
+
+-- | 'extractBitsInto' with a sink instead of a destination vector.
+extractBitsFold ::
+    Int -> Int -> BS.ByteString -> (Int -> Word32 -> ST s ()) -> ST s ()
+extractBitsFold bw count bs sink = go 0 (0 :: Word64) 0 0
+  where
+    !mask = if bw == 32 then maxBound else (1 `unsafeShiftL` bw) - 1 :: Word64
+    !len = BS.length bs
+    go !byteIdx !acc !accBits !done
+        | done >= count = return ()
+        | accBits >= bw = do
+            sink done (fromIntegral (acc .&. mask))
+            go byteIdx (acc `unsafeShiftR` bw) (accBits - bw) (done + 1)
+        | byteIdx >= len = return ()
+        | otherwise =
+            let b = fromIntegral (BSU.unsafeIndex bs byteIdx) :: Word64
+             in go (byteIdx + 1) (acc .|. (b `unsafeShiftL` accBits)) (accBits + 8) done
+{-# INLINE extractBitsFold #-}
diff --git a/src/DataFrame/IO/Parquet/Levels.hs b/src/DataFrame/IO/Parquet/Levels.hs
--- a/src/DataFrame/IO/Parquet/Levels.hs
+++ b/src/DataFrame/IO/Parquet/Levels.hs
@@ -23,14 +23,7 @@
  )
 import DataFrame.Internal.Binary (littleEndianWord32)
 
--- ---------------------------------------------------------------------------
--- Level readers
--- ---------------------------------------------------------------------------
-
-{- | Convert a 'Word32' level vector to an 'Int' level vector while counting
-how many entries equal @maxDef@. Single pass; allocates a single
-'VU.Vector Int' of length @VU.length raw@.
--}
+-- | Convert a 'Word32' level vector to 'Int', counting @maxDef@ entries.
 convertAndCount :: Int -> VU.Vector Word32 -> (VU.Vector Int, Int)
 convertAndCount maxDef raw = runST $ do
     let !n = VU.length raw
@@ -65,8 +58,8 @@
         (defVec, nPresent, afterDef) = decodeLevelBlock bwDef n afterRep
      in (defVec, repVec, nPresent, afterDef)
   where
-    -- For rep block we don't need nPresent; we still get one cheaply.
-    decodeLevelBlock 0 n' buf = (VU.replicate n' 0, n' * fromEnum (maxDef == 0), buf)
+    -- No levels to read: non-nullable, non-repeated paths never use them.
+    decodeLevelBlock 0 n' buf = (VU.empty, n' * fromEnum (maxDef == 0), buf)
     decodeLevelBlock bw n' buf =
         let blockLen = fromIntegral (littleEndianWord32 (BS.take 4 buf)) :: Int
             blockData = BS.take blockLen (BS.drop 4 buf)
@@ -94,22 +87,19 @@
         bwRep = bitWidthForMaxLevel maxRep
         bwDef = bitWidthForMaxLevel maxDef
         repVec
-            | bwRep == 0 = VU.replicate n 0
+            | bwRep == 0 = VU.empty
             | otherwise =
                 let (raw, _) = decodeRLEBitPackedHybrid bwRep n repBytes
                     (out, _) = convertAndCount maxDef raw
                  in out
         (defVec, nPresent)
-            | bwDef == 0 = (VU.replicate n 0, n * fromEnum (maxDef == 0))
+            | bwDef == 0 = (VU.empty, n * fromEnum (maxDef == 0))
             | otherwise =
                 let (raw, _) = decodeRLEBitPackedHybrid bwDef n defBytes
                  in convertAndCount maxDef raw
      in (defVec, repVec, nPresent, afterDefBytes)
 
-{- | Stitch a singly-nested list column (@maxRep == 1@) from vector-format
-definition and repetition levels plus a compact present-values vector.
-Returns one @Maybe [Maybe a]@ per top-level row.
--}
+-- | Stitch a singly-nested list column from levels and present values.
 stitchList ::
     Int ->
     VU.Vector Int ->
@@ -123,9 +113,7 @@
     toRow ((_, d, _) : _) | d == 0 = Nothing
     toRow grp = Just [v | (_, _, v) <- grp]
 
-{- | Stitch a doubly-nested list column (@maxRep == 2@).
-@defT1@ is the def threshold at which the depth-1 element is present.
--}
+-- | Stitch a doubly-nested list column (@maxRep == 2@).
 stitchList2 ::
     Int ->
     Int ->
@@ -146,10 +134,7 @@
     toLeaf [] = Nothing
     toLeaf ((_, _, v) : _) = v
 
-{- | Stitch a triply-nested list column (@maxRep == 3@).
-@defT1@ and @defT2@ are the def thresholds for depth-1 and depth-2
-elements respectively.
--}
+-- | Stitch a triply-nested list column (@maxRep == 3@).
 stitchList3 ::
     Int ->
     Int ->
@@ -174,14 +159,7 @@
     toLeaf [] = Nothing
     toLeaf ((_, _, v) : _) = v
 
--- ---------------------------------------------------------------------------
--- Internal helpers
--- ---------------------------------------------------------------------------
-
-{- | Zip rep and def level vectors with a present-values vector, tagging each
-position as @Just value@ (when @def == maxDef@) or @Nothing@.
-Returns a flat list of @(rep, def, Maybe a)@ triplets for row-splitting.
--}
+-- | Zip rep and def levels with present values into triplets.
 pairWithValsV ::
     Int ->
     VU.Vector Int ->
@@ -200,9 +178,7 @@
                     then (r, d, Just (VB.unsafeIndex values j)) : go (i + 1) (j + 1)
                     else (r, d, Nothing) : go (i + 1) j
 
-{- | Group a flat triplet list into rows.
-A new group begins whenever @rep <= bound@.
--}
+-- | Group a flat triplet list into rows.
 splitAtRepBound :: Int -> [(Int, Int, Maybe a)] -> [[(Int, Int, Maybe a)]]
 splitAtRepBound _ [] = []
 splitAtRepBound bound (t : ts) =
diff --git a/src/DataFrame/IO/Parquet/Page.hs b/src/DataFrame/IO/Parquet/Page.hs
--- a/src/DataFrame/IO/Parquet/Page.hs
+++ b/src/DataFrame/IO/Parquet/Page.hs
@@ -19,6 +19,7 @@
     foldColumnPagesM,
     foldColumnDataPagesM,
     RawPage,
+    decodeRequiredNumericInto,
     appendStringPageIO,
     appendNullableStringPageIO,
 ) where
@@ -36,13 +37,21 @@
 import qualified Data.Vector as VB
 import qualified Data.Vector.Generic as VG
 import qualified Data.Vector.Unboxed as VU
+import qualified Data.Vector.Unboxed.Mutable as VUM
 import Data.Word (Word8)
 import DataFrame.IO.Parquet.Decompress (decompressData)
 import DataFrame.IO.Parquet.Dictionary (
     DictVals (..),
+    dictText,
     readDictVals,
+    word32At,
+    word64At,
  )
-import DataFrame.IO.Parquet.Encoding (decodeDictIndices)
+import DataFrame.IO.Parquet.Encoding (
+    decodeDictIndices,
+    decodeRLEBitPackedHybrid,
+    rleHybridFold,
+ )
 import DataFrame.IO.Parquet.Levels (readLevelsV1, readLevelsV2)
 import DataFrame.IO.Parquet.Thrift (
     ColumnChunk (..),
@@ -60,15 +69,10 @@
 import DataFrame.IO.Parquet.Time (int96ToUTCTime)
 import DataFrame.IO.Parquet.Utils (ColumnDescription (..))
 import DataFrame.IO.Utils.RandomAccess (RandomAccess (..), Range (Range))
-import DataFrame.Internal.Binary (
-    littleEndianInt32,
-    littleEndianWord32,
-    littleEndianWord64,
- )
+import DataFrame.Internal.Binary (littleEndianInt32)
 import DataFrame.Internal.Column.Builder (
     TextBuilder,
     appendNull,
-    appendText,
     appendTextSliceFromPtr,
  )
 import Foreign.Ptr (Ptr, castPtr, plusPtr)
@@ -77,58 +81,45 @@
 import Pinch (decodeWithLeftovers)
 import qualified Pinch
 
--- ---------------------------------------------------------------------------
--- Types
--- ---------------------------------------------------------------------------
-
-{- | A type-specific page decoder.
-Given the optional dictionary, the page encoding, the number of present
-values, and the decompressed value bytes, returns exactly @nPresent@ values.
--}
+-- | A type-specific page decoder.
 type PageDecoder a =
     Maybe DictVals -> Encoding -> Int -> BS.ByteString -> VB.Vector a
 
 type UnboxedPageDecoder a =
     Maybe DictVals -> Encoding -> Int -> BS.ByteString -> VU.Vector a
 
--- ---------------------------------------------------------------------------
--- Per-type decoders
--- ---------------------------------------------------------------------------
-
 boolDecoder :: UnboxedPageDecoder Bool
 boolDecoder mDict enc nPresent bs = case enc of
-    -- PLAIN bools are bit-packed (1 bit/value, LSB-first). Generate the
-    -- unboxed vector directly by indexing the bit for each row, avoiding an
-    -- intermediate @[Bool]@ list.
+    -- PLAIN bools are bit-packed; index the bits directly, avoiding a list.
     PLAIN _ ->
         VU.generate nPresent $ \i ->
             (BSU.unsafeIndex bs (i `shiftR` 3) `shiftR` (i .&. 7)) .&. 1 == 1
-    RLE_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getBool
-    PLAIN_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getBool
+    RLE_DICTIONARY _ -> gatherDict "boolDecoder" getBool mDict nPresent bs
+    PLAIN_DICTIONARY _ -> gatherDict "boolDecoder" getBool mDict nPresent bs
     _ -> error ("boolDecoder: unsupported encoding " ++ show enc)
   where
-    getBool (DBool ds) i = ds VB.! i
-    getBool d _ = error ("boolDecoder: wrong dict type, got " ++ show d)
+    getBool (DBool ds) = Just ds
+    getBool _ = Nothing
 
 int32Decoder :: UnboxedPageDecoder Int32
 int32Decoder mDict enc nPresent bs = case enc of
-    PLAIN _ -> VU.convert (readNInt32 nPresent bs)
-    RLE_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getInt32
-    PLAIN_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getInt32
+    PLAIN _ -> readNInt32 nPresent bs
+    RLE_DICTIONARY _ -> gatherDict "int32Decoder" getInt32 mDict nPresent bs
+    PLAIN_DICTIONARY _ -> gatherDict "int32Decoder" getInt32 mDict nPresent bs
     _ -> error ("int32Decoder: unsupported encoding " ++ show enc)
   where
-    getInt32 (DInt32 ds) i = ds VB.! i
-    getInt32 d _ = error ("int32Decoder: wrong dict type, got " ++ show d)
+    getInt32 (DInt32 ds) = Just ds
+    getInt32 _ = Nothing
 
 int64Decoder :: UnboxedPageDecoder Int64
 int64Decoder mDict enc nPresent bs = case enc of
-    PLAIN _ -> VU.convert (readNInt64 nPresent bs)
-    RLE_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getInt64
-    PLAIN_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getInt64
+    PLAIN _ -> readNInt64 nPresent bs
+    RLE_DICTIONARY _ -> gatherDict "int64Decoder" getInt64 mDict nPresent bs
+    PLAIN_DICTIONARY _ -> gatherDict "int64Decoder" getInt64 mDict nPresent bs
     _ -> error ("int64Decoder: unsupported encoding " ++ show enc)
   where
-    getInt64 (DInt64 ds) i = ds VB.! i
-    getInt64 d _ = error ("int64Decoder: wrong dict type, got " ++ show d)
+    getInt64 (DInt64 ds) = Just ds
+    getInt64 _ = Nothing
 
 int96Decoder :: PageDecoder UTCTime
 int96Decoder mDict enc nPresent bs = case enc of
@@ -137,28 +128,28 @@
     PLAIN_DICTIONARY _ -> lookupDict mDict nPresent bs getInt96
     _ -> error ("int96Decoder: unsupported encoding " ++ show enc)
   where
-    getInt96 (DInt96 ds) i = ds VB.! i
+    getInt96 (DInt96 ds) i = VB.unsafeIndex ds i
     getInt96 d _ = error ("int96Decoder: wrong dict type, got " ++ show d)
 
 floatDecoder :: UnboxedPageDecoder Float
 floatDecoder mDict enc nPresent bs = case enc of
-    PLAIN _ -> VU.convert (readNFloat nPresent bs)
-    RLE_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getFloat
-    PLAIN_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getFloat
+    PLAIN _ -> readNFloat nPresent bs
+    RLE_DICTIONARY _ -> gatherDict "floatDecoder" getFloat mDict nPresent bs
+    PLAIN_DICTIONARY _ -> gatherDict "floatDecoder" getFloat mDict nPresent bs
     _ -> error ("floatDecoder: unsupported encoding " ++ show enc)
   where
-    getFloat (DFloat ds) i = ds VB.! i
-    getFloat d _ = error ("floatDecoder: wrong dict type, got " ++ show d)
+    getFloat (DFloat ds) = Just ds
+    getFloat _ = Nothing
 
 doubleDecoder :: UnboxedPageDecoder Double
 doubleDecoder mDict enc nPresent bs = case enc of
-    PLAIN _ -> VU.convert (readNDouble nPresent bs)
-    RLE_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getDouble
-    PLAIN_DICTIONARY _ -> unboxedLookupDict mDict nPresent bs getDouble
+    PLAIN _ -> readNDouble nPresent bs
+    RLE_DICTIONARY _ -> gatherDict "doubleDecoder" getDouble mDict nPresent bs
+    PLAIN_DICTIONARY _ -> gatherDict "doubleDecoder" getDouble mDict nPresent bs
     _ -> error ("doubleDecoder: unsupported encoding " ++ show enc)
   where
-    getDouble (DDouble ds) i = ds VB.! i
-    getDouble d _ = error ("doubleDecoder: wrong dict type, got " ++ show d)
+    getDouble (DDouble ds) = Just ds
+    getDouble _ = Nothing
 
 byteArrayDecoder :: PageDecoder T.Text
 byteArrayDecoder mDict enc nPresent bs = case enc of
@@ -167,8 +158,7 @@
     PLAIN_DICTIONARY _ -> lookupDict mDict nPresent bs getText
     _ -> error ("byteArrayDecoder: unsupported encoding " ++ show enc)
   where
-    getText (DText ds) i = ds VB.! i
-    getText d _ = error ("byteArrayDecoder: wrong dict type, got " ++ show d)
+    getText = dictText
 
 fixedLenByteArrayDecoder :: Int -> PageDecoder T.Text
 fixedLenByteArrayDecoder len mDict enc nPresent bs = case enc of
@@ -177,12 +167,9 @@
     PLAIN_DICTIONARY _ -> lookupDict mDict nPresent bs getText
     _ -> error ("fixedLenByteArrayDecoder: unsupported encoding " ++ show enc)
   where
-    getText (DText ds) i = ds VB.! i
-    getText d _ = error ("fixedLenByteArrayDecoder: wrong dict type, got " ++ show d)
+    getText = dictText
 
-{- | Shared dictionary-path helper: decode @nPresent@ RLE/bit-packed indices
-and look each one up in the dictionary.
--}
+-- | Decode RLE\/bit-packed indices and look each up in the dictionary.
 lookupDict ::
     Maybe DictVals ->
     Int ->
@@ -195,22 +182,25 @@
         let (idxs, _) = decodeDictIndices nPresent bs
          in VB.generate nPresent (f dict . VU.unsafeIndex idxs)
 
-unboxedLookupDict ::
+-- | Decode dictionary indices and gather from an unboxed dictionary.
+gatherDict ::
     (VU.Unbox a) =>
+    String ->
+    (DictVals -> Maybe (VU.Vector a)) ->
     Maybe DictVals ->
     Int ->
     BS.ByteString ->
-    (DictVals -> Int -> a) ->
     VU.Vector a
-unboxedLookupDict mDict nPresent bs f = case mDict of
-    Nothing -> error "Dictionary-encoded page but no dictionary page seen"
-    Just dict ->
-        let (idxs, _) = decodeDictIndices nPresent bs
-         in VU.generate nPresent (f dict . VU.unsafeIndex idxs)
-
--- ---------------------------------------------------------------------------
--- Core page-iteration loop
--- ---------------------------------------------------------------------------
+gatherDict who pick mDict nPresent bs = case mDict of
+    Nothing -> error (who ++ ": dictionary-encoded page but no dictionary page seen")
+    Just dict -> case pick dict of
+        Nothing -> error (who ++ ": wrong dict type, got " ++ show dict)
+        Just ds -> case BS.uncons bs of
+            Nothing -> error (who ++ ": empty index stream")
+            Just (w0, rest) ->
+                let (idxs, _) = decodeRLEBitPackedHybrid (fromIntegral w0) nPresent rest
+                 in VU.map (VU.unsafeIndex ds . fromIntegral) idxs
+{-# INLINE gatherDict #-}
 
 -- | Read the raw (compressed) byte range for a column chunk.
 readChunkBytes ::
@@ -228,30 +218,11 @@
     rawBytes <- readBytes (Range offset compLen)
     return (codec, pType, rawBytes)
 
-{- | A decoded DATA page handed to a fold step: the running dictionary, the
-page encoding, the present-value count, the (decompressed) value bytes, and the
-definition/repetition level vectors.
--}
+-- | A decoded DATA page handed to a fold step.
 type RawPage =
     (Maybe DictVals, Encoding, Int, BS.ByteString, VU.Vector Int, VU.Vector Int)
 
-{- | Left-fold a monadic step over every DATA page (V1 or V2) of every column
-chunk, in order, threading the running dictionary internally and handing each
-page to @step@ as a 'RawPage' (no value decoding — the step decides how to
-materialize, e.g. into a typed vector or straight into a text buffer).
-
-Dictionary pages update the running dictionary; @INDEX_PAGE@s are skipped. Only
-one page's bytes are live at a time.
-
--- TODO: when a page index is available, use it here to compute which page
--- byte ranges to request from the RandomAccess layer instead of reading the
--- entire column chunk in one contiguous read.
-
--- TODO: accept an optional row-range and use the column/offset page index
--- (when present in file metadata) to skip pages whose row range does not
--- overlap the requested range, avoiding decompression of irrelevant pages
--- entirely.
--}
+-- | Left-fold a monadic step over every DATA page of every column chunk.
 {-# INLINEABLE foldColumnDataPagesM #-}
 foldColumnDataPagesM ::
     forall m acc.
@@ -311,8 +282,7 @@
                             enc = unField dph2.dph2_encoding
                             defLen = unField dph2.dph2_definition_levels_byte_length
                             repLen = unField dph2.dph2_repetition_levels_byte_length
-                            -- V2: levels are never compressed; only the value
-                            -- payload is (optionally) compressed.
+                            -- V2 levels are never compressed, only the values.
                             isCompressed = fromMaybe True (unField dph2.dph2_is_compressed)
                             (defLvls, repLvls, nPresent, compValBytes) =
                                 readLevelsV2 n maxDef maxRep repLen defLen pageData
@@ -324,10 +294,7 @@
                         goPages dict codec pType rest' acc'
                     INDEX_PAGE _ -> goPages dict codec pType rest' acc
 
-{- | Left-fold over per-page value triples, decoding each page with @decoder@.
-A thin wrapper over 'foldColumnDataPagesM' for the typed (numeric/boxed) column
-paths.
--}
+-- | Left-fold over per-page value triples with a given decoder.
 {-# INLINEABLE foldColumnPagesM #-}
 foldColumnPagesM ::
     forall m v a acc.
@@ -343,14 +310,7 @@
         \acc (dict, enc, nPresent, valBytes, defLvls, repLvls) ->
             step acc (decoder dict enc nPresent valBytes, defLvls, repLvls)
 
-{- | Append a non-nullable BYTE_ARRAY page's strings straight into a
-'TextBuilder', no intermediate boxed 'Text' vector.
-
-PLAIN pages copy each value's UTF-8 bytes directly from the page buffer pointer
-('appendTextSliceFromPtr'), skipping the per-value 'decodeUtf8Lenient' +
-'ByteString' slicing + 'Text' allocation that @readNTexts@ would do.
-Dictionary pages append the shared dictionary 'Text's by reference.
--}
+-- | Append a non-nullable BYTE_ARRAY page's strings into a 'TextBuilder'.
 appendStringPageIO ::
     TextBuilder RealWorld ->
     Maybe DictVals ->
@@ -372,15 +332,21 @@
     _ -> error ("appendStringPageIO: unsupported encoding " ++ show enc)
   where
     dictAppend = case mDict of
-        Just (DText ds) ->
-            let (idxs, _) = decodeDictIndices nPresent bs
-             in stToIO (VU.mapM_ (\i -> appendText builder (ds VB.! i)) idxs)
+        Just (DText starts lens dictBytes) ->
+            BSU.unsafeUseAsCStringLen dictBytes $ \(cptr, _) -> do
+                let p = castPtr cptr :: Ptr Word8
+                    (idxs, _) = decodeDictIndices nPresent bs
+                VU.forM_ idxs $ \i ->
+                    stToIO
+                        ( appendTextSliceFromPtr
+                            builder
+                            (p `plusPtr` VU.unsafeIndex starts i)
+                            (VU.unsafeIndex lens i)
+                        )
         Just d -> error ("appendStringPageIO: wrong dict type, got " ++ show d)
         Nothing -> error "appendStringPageIO: dictionary-encoded page but no dictionary seen"
 
-{- | Read a little-endian 4-byte length prefix at byte @off@ from a raw page
-pointer.
--}
+-- | Read a little-endian 4-byte length prefix from a raw page pointer.
 readLenLE :: Ptr Word8 -> Int -> IO Int
 readLenLE p off = do
     b0 <- peekByteOff p off :: IO Word8
@@ -394,13 +360,7 @@
             .|. (fromIntegral b3 `shiftL` 24)
 {-# INLINE readLenLE #-}
 
-{- | Append a NULLABLE BYTE_ARRAY page into a 'TextBuilder', interleaving nulls
-by walking the definition levels: a present row (@def == maxDef@) consumes the
-next stored value (dictionary entry by reference, or the next length-prefixed
-PLAIN slice by memcpy); a null row ('appendNull') writes an offset and clears a
-validity bit. Builds 'PackedText' + validity bitmap with no per-row 'Text' or
-@[Maybe a]@ allocation. Only present values are stored in the page payload.
--}
+-- | Append a nullable BYTE_ARRAY page into a 'TextBuilder'.
 appendNullableStringPageIO ::
     TextBuilder RealWorld ->
     Int ->
@@ -427,13 +387,20 @@
   where
     nDefs = VU.length defs
     dictAppend = case mDict of
-        Just (DText ds) -> do
-            let (idxs, _) = decodeDictIndices nPresent bs
+        Just (DText starts lens dictBytes) -> BSU.unsafeUseAsCStringLen dictBytes $ \(cptr, _) -> do
+            let p = castPtr cptr :: Ptr Word8
+                (idxs, _) = decodeDictIndices nPresent bs
                 go !_ !i | i >= nDefs = pure ()
                 go !j !i
-                    | VU.unsafeIndex defs i == maxDef =
-                        stToIO (appendText builder (ds VB.! VU.unsafeIndex idxs j))
-                            >> go (j + 1) (i + 1)
+                    | VU.unsafeIndex defs i == maxDef = do
+                        let d = VU.unsafeIndex idxs j
+                        stToIO
+                            ( appendTextSliceFromPtr
+                                builder
+                                (p `plusPtr` VU.unsafeIndex starts d)
+                                (VU.unsafeIndex lens d)
+                            )
+                        go (j + 1) (i + 1)
                     | otherwise = stToIO (appendNull builder) >> go j (i + 1)
             go 0 0
         Just d -> error ("appendNullableStringPageIO: wrong dict type, got " ++ show d)
@@ -441,35 +408,24 @@
             error
                 "appendNullableStringPageIO: dictionary-encoded page but no dictionary seen"
 
--- ---------------------------------------------------------------------------
--- Page header parsing
--- ---------------------------------------------------------------------------
-
 parsePageHeader :: BS.ByteString -> Either String (BS.ByteString, PageHeader)
 parsePageHeader = decodeWithLeftovers Pinch.compactProtocol
 
--- ---------------------------------------------------------------------------
--- Batch value readers
--- ---------------------------------------------------------------------------
-
 readNInt32 :: Int -> BS.ByteString -> VU.Vector Int32
-readNInt32 n bs = VU.generate n $ \i -> littleEndianInt32 (BS.drop (4 * i) bs)
+readNInt32 n bs = VU.generate n $ \i -> fromIntegral (word32At bs (4 * i))
 
 readNInt64 :: Int -> BS.ByteString -> VU.Vector Int64
-readNInt64 n bs = VU.generate n $ \i ->
-    fromIntegral (littleEndianWord64 (BS.drop (8 * i) bs))
+readNInt64 n bs = VU.generate n $ \i -> fromIntegral (word64At bs (8 * i))
 
 readNInt96 :: Int -> BS.ByteString -> [UTCTime]
 readNInt96 0 _ = []
 readNInt96 n bs = int96ToUTCTime (BS.take 12 bs) : readNInt96 (n - 1) (BS.drop 12 bs)
 
 readNFloat :: Int -> BS.ByteString -> VU.Vector Float
-readNFloat n bs = VU.generate n $ \i ->
-    castWord32ToFloat (littleEndianWord32 (BS.drop (4 * i) bs))
+readNFloat n bs = VU.generate n $ \i -> castWord32ToFloat (word32At bs (4 * i))
 
 readNDouble :: Int -> BS.ByteString -> VU.Vector Double
-readNDouble n bs = VU.generate n $ \i ->
-    castWord64ToDouble (littleEndianWord64 (BS.drop (8 * i) bs))
+readNDouble n bs = VU.generate n $ \i -> castWord64ToDouble (word64At bs (8 * i))
 
 readNTexts :: Int -> BS.ByteString -> [T.Text]
 readNTexts 0 _ = []
@@ -483,3 +439,100 @@
 readNFixedTexts len n bs =
     decodeUtf8Lenient (BS.take len bs)
         : readNFixedTexts len (n - 1) (BS.drop len bs)
+
+-- | Decode a required numeric column chunk straight into @out[off ..]@.
+decodeRequiredNumericInto ::
+    forall a m.
+    (VU.Unbox a, RandomAccess m, MonadIO m) =>
+    (DictVals -> Maybe (VU.Vector a)) ->
+    (BS.ByteString -> Int -> a) ->
+    Int ->
+    ColumnDescription ->
+    ColumnChunk ->
+    VUM.IOVector a ->
+    Int ->
+    m Int
+decodeRequiredNumericInto pick plainAt width description chunk out off0 = do
+    (codec, pType, rawBytes) <- readChunkBytes chunk
+    goPages Nothing codec pType rawBytes off0
+  where
+    goPages ::
+        Maybe (VU.Vector a) ->
+        CompressionCodec ->
+        ThriftType ->
+        BS.ByteString ->
+        Int ->
+        m Int
+    goPages dict codec pType bs !off
+        | BS.null bs = return off
+        | otherwise = case parsePageHeader bs of
+            Left e -> error ("decodeRequiredNumericInto: failed to parse page header: " ++ e)
+            Right (rest, hdr) -> do
+                let compSz = fromIntegral . unField $ hdr.ph_compressed_page_size
+                    uncmpSz = fromIntegral . unField $ hdr.ph_uncompressed_page_size
+                    (pageData, rest') = BS.splitAt compSz rest
+                case unField hdr.ph_type of
+                    DICTIONARY_PAGE _ -> do
+                        let dictHdr =
+                                fromMaybe
+                                    (error "DICTIONARY_PAGE: missing dictionary page header")
+                                    (unField hdr.ph_dictionary_page_header)
+                            numVals = unField dictHdr.diph_num_values
+                        decompressed <- liftIO $ decompressData uncmpSz codec pageData
+                        let d = readDictVals pType decompressed numVals description.typeLength
+                        case pick d of
+                            Nothing -> error "decodeRequiredNumericInto: dictionary page mistyped"
+                            Just !ds -> goPages (Just ds) codec pType rest' off
+                    DATA_PAGE _ -> do
+                        let dph =
+                                fromMaybe
+                                    (error "DATA_PAGE: missing data page header")
+                                    (unField hdr.ph_data_page_header)
+                            n = fromIntegral . unField $ dph.dph_num_values
+                            enc = unField dph.dph_encoding
+                        decompressed <- liftIO $ decompressData uncmpSz codec pageData
+                        liftIO (writePage dict enc n decompressed off)
+                        goPages dict codec pType rest' (off + n)
+                    DATA_PAGE_V2 _ -> do
+                        let dph2 =
+                                fromMaybe
+                                    (error "DATA_PAGE_V2: missing data page header v2")
+                                    (unField hdr.ph_data_page_header_v2)
+                            n = fromIntegral . unField $ dph2.dph2_num_values
+                            enc = unField dph2.dph2_encoding
+                            levelBytes =
+                                fromIntegral (unField dph2.dph2_definition_levels_byte_length)
+                                    + fromIntegral (unField dph2.dph2_repetition_levels_byte_length)
+                            isCompressed = fromMaybe True (unField dph2.dph2_is_compressed)
+                            compValBytes = BS.drop levelBytes pageData
+                        valBytes <-
+                            if isCompressed
+                                then liftIO $ decompressData (uncmpSz - levelBytes) codec compValBytes
+                                else pure compValBytes
+                        liftIO (writePage dict enc n valBytes off)
+                        goPages dict codec pType rest' (off + n)
+                    INDEX_PAGE _ -> goPages dict codec pType rest' off
+
+    writePage ::
+        Maybe (VU.Vector a) -> Encoding -> Int -> BS.ByteString -> Int -> IO ()
+    writePage dict enc n vals off = case enc of
+        PLAIN _ ->
+            let go !i
+                    | i >= n = pure ()
+                    | otherwise = do
+                        VUM.unsafeWrite out (off + i) (plainAt vals (width * i))
+                        go (i + 1)
+             in go 0
+        RLE_DICTIONARY _ -> gather
+        PLAIN_DICTIONARY _ -> gather
+        _ -> error ("decodeRequiredNumericInto: unsupported encoding " ++ show enc)
+      where
+        gather = case dict of
+            Nothing -> error "decodeRequiredNumericInto: dictionary page missing"
+            Just ds -> case BS.uncons vals of
+                Nothing -> error "decodeRequiredNumericInto: empty index stream"
+                Just (w0, rest) ->
+                    stToIO $
+                        rleHybridFold (fromIntegral w0) n rest $ \i w ->
+                            VUM.unsafeWrite out (off + i) (VU.unsafeIndex ds (fromIntegral w))
+{-# INLINE decodeRequiredNumericInto #-}
diff --git a/src/DataFrame/IO/Parquet/Schema.hs b/src/DataFrame/IO/Parquet/Schema.hs
--- a/src/DataFrame/IO/Parquet/Schema.hs
+++ b/src/DataFrame/IO/Parquet/Schema.hs
@@ -3,15 +3,7 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TypeApplications #-}
 
-{- |
-Module      : DataFrame.IO.Parquet.Schema
-License     : MIT
-
-Helpers for converting a parquet schema (a list of 'SchemaElement') into an
-empty 'DataFrame' whose columns have the right types but no rows. Used by
-'DataFrame.TH.declareColumnsFromParquetFile' and by any tooling that wants
-to inspect a parquet schema as a 'DataFrame'.
--}
+-- | Converts a parquet schema into an empty 'DataFrame'.
 module DataFrame.IO.Parquet.Schema (
     schemaToEmptyDataFrame,
     schemaElemToColumn,
@@ -33,21 +25,16 @@
     unField,
  )
 import DataFrame.Internal.Column (Column, fromList)
-import DataFrame.Internal.DataFrame (DataFrame, fromNamedColumns)
+import DataFrame.Internal.DataFrame (DataFrame, fromColumns)
 
-{- | Build an empty 'DataFrame' from a flat list of parquet 'SchemaElement's.
-Only leaf elements (those with no children) become columns. Columns whose
-name is in @nullableCols@ are typed as @Maybe a@; the rest are typed as @a@.
--}
+-- | Build an empty 'DataFrame' from a flat list of 'SchemaElement's.
 schemaToEmptyDataFrame :: S.Set T.Text -> [SchemaElement] -> DataFrame
 schemaToEmptyDataFrame nullableCols elems =
     let leafElems =
             filter (\e -> Maybe.fromMaybe 0 (unField e.num_children) == 0) elems
-     in fromNamedColumns (map (schemaElemToColumn nullableCols) leafElems)
+     in fromColumns (map (schemaElemToColumn nullableCols) leafElems)
 
-{- | Convert a single parquet 'SchemaElement' into a named empty 'Column',
-picking a nullable or non-nullable representation based on @nullableCols@.
--}
+-- | Convert a single 'SchemaElement' into a named empty 'Column'.
 schemaElemToColumn :: S.Set T.Text -> SchemaElement -> (T.Text, Column)
 schemaElemToColumn nullableCols element =
     let colName = unField element.name
diff --git a/src/DataFrame/IO/Parquet/Seeking.hs b/src/DataFrame/IO/Parquet/Seeking.hs
--- a/src/DataFrame/IO/Parquet/Seeking.hs
+++ b/src/DataFrame/IO/Parquet/Seeking.hs
@@ -1,7 +1,4 @@
-{- | This module contains low-level utilities around file seeking.
-
-later this module can be renamed / moved to an internal module.
--}
+-- | Low-level utilities around file seeking.
 module DataFrame.IO.Parquet.Seeking (
     SeekableHandle (getSeekableHandle),
     SeekMode (..),
@@ -22,28 +19,10 @@
 import Data.Int
 import System.IO
 
-{- | This handle carries a proof that it must be seekable.
-Note: Handle and SeekableHandle are not thread safe, should not be
-shared across threads, beaware when running parallel/concurrent code.
-
-Not seekable:
-  - stdin / stdout
-  - pipes / FIFOs
-
-But regular files are always seekable. Parquet fundamentally wants random
-access, a non-seekable source will not support effecient access without
-buffering the entire file.
--}
+-- | A handle that carries a proof that it is seekable.
 newtype SeekableHandle = SeekableHandle {getSeekableHandle :: Handle}
 
-{- | If we truely want to support non-seekable files, we need to also consider the case
-to buffer the entire file in memory.
-
-Not thread safe, contains mutable reference (as Handle already is).
-
-If we need concurrent / parallel parsing or something, we need to read into ByteString
-first, not sharing the same handle.
--}
+-- | Handle that is either seekable or buffered in memory.
 data FileBufferedOrSeekable
     = FileBuffered !(IORef Int64) !BS.ByteString
     | FileSeekable !SeekableHandle
@@ -57,9 +36,7 @@
 -- | For testing only
 type ForceNonSeekable = Maybe Bool
 
-{- | Smart constructor for FileBufferedOrSeekable, tries to keep in the seekable case
-if possible.
--}
+-- | Smart constructor that stays in the seekable case when possible.
 mkFileBufferedOrSeekable ::
     ForceNonSeekable -> Handle -> IO FileBufferedOrSeekable
 mkFileBufferedOrSeekable forceNonSeek h = do
@@ -68,11 +45,7 @@
         then FileBuffered <$> newIORef 0 <*> BS.hGetContents h
         else pure $ FileSeekable $ SeekableHandle h
 
-{- | With / bracket pattern for FileBufferedOrSeekable
-
-Warning: do not return the FileBufferedOrSeekable outside the scope of the action as
-it will be closed.
--}
+-- | Bracket-style access to a 'FileBufferedOrSeekable'.
 withFileBufferedOrSeekable ::
     ForceNonSeekable ->
     FilePath ->
@@ -83,10 +56,7 @@
     fbos <- mkFileBufferedOrSeekable forceNonSeek h
     action fbos
 
-{- | Read the last @n@ bytes, useful for reading metadata without loading the
-entire file. Uses 'BS.hGet' (not @hGetContents@) so the handle stays open for
-the subsequent column-chunk reads.
--}
+-- | Read the last @n@ bytes, e.g. to get metadata without the whole file.
 readLastBytes :: Integer -> FileBufferedOrSeekable -> IO BS.ByteString
 readLastBytes n (FileSeekable sh) = do
     let h = getSeekableHandle sh
diff --git a/src/DataFrame/IO/Parquet/Thrift.hs b/src/DataFrame/IO/Parquet/Thrift.hs
--- a/src/DataFrame/IO/Parquet/Thrift.hs
+++ b/src/DataFrame/IO/Parquet/Thrift.hs
@@ -12,8 +12,7 @@
 import Pinch (Enumeration, Field, Pinchable (..))
 import qualified Pinch
 
--- Primitive Parquet Types
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L32
+-- Primitive Parquet Types (parquet.thrift#L32)
 data ThriftType
     = BOOLEAN (Enumeration 0)
     | INT32 (Enumeration 1)
@@ -27,7 +26,7 @@
 
 instance Pinchable ThriftType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L183
+-- parquet.thrift#L183
 data FieldRepetitionType
     = REQUIRED (Enumeration 0)
     | OPTIONAL (Enumeration 1)
@@ -36,11 +35,11 @@
 
 instance Pinchable FieldRepetitionType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L203
+-- parquet.thrift#L203
 data Encoding
     = PLAIN (Enumeration 0)
     | -- GROUP_VAR_INT Encoding was never used
-      -- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L578
+      -- parquet.thrift#L578
       PLAIN_DICTIONARY (Enumeration 2)
     | RLE (Enumeration 3)
     | BIT_PACKED (Enumeration 4)
@@ -53,7 +52,7 @@
 
 instance Pinchable Encoding
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L244
+-- parquet.thrift#L244
 data CompressionCodec
     = UNCOMPRESSED (Enumeration 0)
     | SNAPPY (Enumeration 1)
@@ -67,7 +66,7 @@
 
 instance Pinchable CompressionCodec
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L261
+-- parquet.thrift#L261
 data PageType
     = DATA_PAGE (Enumeration 0)
     | INDEX_PAGE (Enumeration 1)
@@ -77,7 +76,7 @@
 
 instance Pinchable PageType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L271
+-- parquet.thrift#L271
 data BoundaryOrder
     = UNORDERED (Enumeration 0)
     | ASCENDING (Enumeration 1)
@@ -86,12 +85,9 @@
 
 instance Pinchable BoundaryOrder
 
--- Logical type annotations
--- Empty structs can't use deriving Generic with Pinch, so we use a unit-like workaround.
--- We represent empty structs as a newtype over () with a manual Pinchable instance.
+-- Logical type annotations; empty structs use manual Pinchable instances.
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L283
--- struct StringType {}
+-- parquet.thrift#L283: struct StringType {}
 data StringType = StringType deriving (Eq, Show)
 instance Pinchable StringType where
     type Tag StringType = Pinch.TStruct
@@ -158,7 +154,7 @@
     pinch _ = Pinch.struct []
     unpinch _ = pure VariantType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L290
+-- parquet.thrift#L290
 data TimeUnit
     = MILLIS (Field 1 MilliSeconds)
     | MICROS (Field 2 MicroSeconds)
@@ -185,7 +181,7 @@
     pinch _ = Pinch.struct []
     unpinch _ = pure NanoSeconds
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L317
+-- parquet.thrift#L317
 data DecimalType
     = DecimalType
     { decimal_scale :: Field 1 Int32
@@ -195,7 +191,7 @@
 
 instance Pinchable DecimalType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L328
+-- parquet.thrift#L328
 data IntType
     = IntType
     { int_bitWidth :: Field 1 Int8
@@ -205,7 +201,7 @@
 
 instance Pinchable IntType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L338
+-- parquet.thrift#L338
 data TimeType
     = TimeType
     { time_isAdjustedToUTC :: Field 1 Bool
@@ -215,7 +211,7 @@
 
 instance Pinchable TimeType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L349
+-- parquet.thrift#L349
 data TimestampType
     = TimestampType
     { timestamp_isAdjustedToUTC :: Field 1 Bool
@@ -225,8 +221,7 @@
 
 instance Pinchable TimestampType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L360
--- union LogicalType
+-- parquet.thrift#L360: union LogicalType
 data LogicalType
     = LT_STRING (Field 1 StringType)
     | LT_MAP (Field 2 MapType)
@@ -247,7 +242,7 @@
 
 instance Pinchable LogicalType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L270
+-- parquet.thrift#L270
 data ConvertedType
     = UTF8 (Enumeration 0)
     | MAP (Enumeration 1)
@@ -275,10 +270,10 @@
 
 instance Pinchable ConvertedType
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L505
+-- parquet.thrift#L505
 data SchemaElement
     = SchemaElement
-    { schematype :: Field 1 (Maybe ThriftType) -- called just type in parquet.thrift
+    { schematype :: Field 1 (Maybe ThriftType) -- named type upstream
     , type_length :: Field 2 (Maybe Int32)
     , repetition_type :: Field 3 (Maybe FieldRepetitionType)
     , name :: Field 4 Text
@@ -293,7 +288,7 @@
 
 instance Pinchable SchemaElement
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L560
+-- parquet.thrift#L560
 data Statistics
     = Statistics
     { stats_max :: Field 1 (Maybe ByteString)
@@ -309,7 +304,7 @@
 
 instance Pinchable Statistics
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L600
+-- parquet.thrift#L600
 data PageEncodingStats
     = PageEncodingStats
     { pes_page_type :: Field 1 PageType
@@ -320,7 +315,7 @@
 
 instance Pinchable PageEncodingStats
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L614
+-- parquet.thrift#L614
 data ColumnMetaData
     = ColumnMetaData
     { cmd_type :: Field 1 ThriftType
@@ -343,14 +338,14 @@
 
 instance Pinchable ColumnMetaData
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L875
+-- parquet.thrift#L875
 data EncryptionWithFooterKey = EncryptionWithFooterKey deriving (Eq, Show)
 instance Pinchable EncryptionWithFooterKey where
     type Tag EncryptionWithFooterKey = Pinch.TStruct
     pinch _ = Pinch.struct []
     unpinch _ = pure EncryptionWithFooterKey
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L883
+-- parquet.thrift#L883
 data EncryptionWithColumnKey
     = EncryptionWithColumnKey
     { ewck_path_in_schema :: Field 1 [Text]
@@ -360,8 +355,7 @@
 
 instance Pinchable EncryptionWithColumnKey
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L893
--- union ColumnCryptoMetaData
+-- parquet.thrift#L893: union ColumnCryptoMetaData
 data ColumnCryptoMetaData
     = CCM_ENCRYPTION_WITH_FOOTER_KEY (Field 1 EncryptionWithFooterKey)
     | CCM_ENCRYPTION_WITH_COLUMN_KEY (Field 2 EncryptionWithColumnKey)
@@ -369,7 +363,7 @@
 
 instance Pinchable ColumnCryptoMetaData
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L899
+-- parquet.thrift#L899
 data ColumnChunk
     = ColumnChunk
     { cc_file_path :: Field 1 (Maybe Text)
@@ -386,7 +380,7 @@
 
 instance Pinchable ColumnChunk
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L940
+-- parquet.thrift#L940
 data SortingColumn
     = SortingColumn
     { sc_column_idx :: Field 1 Int32
@@ -397,7 +391,7 @@
 
 instance Pinchable SortingColumn
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L958
+-- parquet.thrift#L958
 data RowGroup
     = RowGroup
     { rg_columns :: Field 1 [ColumnChunk]
@@ -412,7 +406,7 @@
 
 instance Pinchable RowGroup
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L980
+-- parquet.thrift#L980
 data KeyValue
     = KeyValue
     { kv_key :: Field 1 Text
@@ -422,8 +416,7 @@
 
 instance Pinchable KeyValue
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L990
--- union ColumnOrder
+-- parquet.thrift#L990: union ColumnOrder
 newtype ColumnOrder
     = TYPE_ORDER (Field 1 TypeDefinedOrder)
     deriving (Eq, Show, Generic)
@@ -437,7 +430,7 @@
     pinch _ = Pinch.struct []
     unpinch _ = pure TypeDefinedOrder
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L1094
+-- parquet.thrift#L1094
 data AesGcmV1
     = AesGcmV1
     { aes_gcm_v1_aad_prefix :: Field 1 (Maybe ByteString)
@@ -448,7 +441,7 @@
 
 instance Pinchable AesGcmV1
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L1107
+-- parquet.thrift#L1107
 data AesGcmCtrV1
     = AesGcmCtrV1
     { aes_gcm_ctr_v1_aad_prefix :: Field 1 (Maybe ByteString)
@@ -459,8 +452,7 @@
 
 instance Pinchable AesGcmCtrV1
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L1118
--- union EncryptionAlgorithm
+-- parquet.thrift#L1118: union EncryptionAlgorithm
 data EncryptionAlgorithm
     = AES_GCM_V1 (Field 1 AesGcmV1)
     | AES_GCM_CTR_V1 (Field 2 AesGcmCtrV1)
@@ -468,7 +460,7 @@
 
 instance Pinchable EncryptionAlgorithm
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L1001
+-- parquet.thrift#L1001
 data PageLocation
     = PageLocation
     { pl_offset :: Field 1 Int64
@@ -479,7 +471,7 @@
 
 instance Pinchable PageLocation
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L1017
+-- parquet.thrift#L1017
 data OffsetIndex
     = OffsetIndex
     { oi_page_locations :: Field 1 [PageLocation]
@@ -489,7 +481,7 @@
 
 instance Pinchable OffsetIndex
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L1033
+-- parquet.thrift#L1033
 data ColumnIndex
     = ColumnIndex
     { ci_null_pages :: Field 1 [Bool]
@@ -504,7 +496,7 @@
 
 instance Pinchable ColumnIndex
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L1248
+-- parquet.thrift#L1248
 data DataPageHeader
     = DataPageHeader
     { dph_num_values :: Field 1 Int32
@@ -563,7 +555,7 @@
 
 instance Pinchable PageHeader
 
--- https://github.com/apache/parquet-format/blob/master/src/main/thrift/parquet.thrift#L1277
+-- parquet.thrift#L1277
 data FileMetadata
     = FileMetadata
     { version :: Field 1 Int32
diff --git a/src/DataFrame/IO/Parquet/Time.hs b/src/DataFrame/IO/Parquet/Time.hs
--- a/src/DataFrame/IO/Parquet/Time.hs
+++ b/src/DataFrame/IO/Parquet/Time.hs
@@ -42,8 +42,7 @@
         year = 100 * b + d - 4800 + m `div` 10
      in fromGregorian year (fromIntegral month) (fromIntegral day)
 
--- I include this here even though it's unused because we'll likely use
--- it for the writer. Since int96 is deprecated this is only included for completeness anyway.
+-- Unused for now; kept for the writer since int96 is deprecated.
 utcTimeToInt96 :: UTCTime -> BS.ByteString
 utcTimeToInt96 (UTCTime day diffTime) =
     let julianDay = dayToJulianDay day
diff --git a/src/DataFrame/IO/Parquet/Utils.hs b/src/DataFrame/IO/Parquet/Utils.hs
--- a/src/DataFrame/IO/Parquet/Utils.hs
+++ b/src/DataFrame/IO/Parquet/Utils.hs
@@ -48,11 +48,7 @@
  )
 import DataFrame.Internal.Column.Bitmap (buildBitmapFromValid)
 
-{- | A left-fold driver over a column's per-page triples
-@(values, def-levels, rep-levels)@, as produced by
-'DataFrame.IO.Parquet.Page.foldColumnPagesM'. Generic in the accumulator so a
-single driver serves every fold strategy below.
--}
+-- | A left-fold driver over a column's per-page triples.
 type PageFold m v a =
     forall acc.
     (acc -> (v a, VU.Vector Int, VU.Vector Int) -> m acc) -> acc -> m acc
@@ -161,12 +157,7 @@
                     childLeaves = go children subPath False
                  in childLeaves ++ go rest path skipThis
 
-{- | Fold a column's value pages into a non-nullable 'Column'.
-
-Pre-allocates a mutable vector of @totalRows@ and fills it page-by-page via a
-single streaming left fold ('PageFold'), avoiding any intermediate list or
-concatenation allocation. Only one page's values are live at a time.
--}
+-- | Fold a column's value pages into a non-nullable 'Column'.
 {-# INLINEABLE foldNonNullable #-}
 foldNonNullable ::
     forall m a.
@@ -200,29 +191,14 @@
         runPages
             ( \off (chunk, _, _) -> liftIO $ do
                 let n = VU.length chunk
-                    go i
-                        | i >= n = return ()
-                        | otherwise = do
-                            VUM.unsafeWrite
-                                mv
-                                (off + i)
-                                (VU.unsafeIndex chunk i)
-                            go (i + 1)
-                go 0
+                VU.unsafeCopy (VUM.unsafeSlice off n mv) chunk
                 return (off + n)
             )
             (0 :: Int)
     dat <- liftIO $ VU.unsafeFreeze mv
     return (UnboxedColumn Nothing dat)
 
-{- | Fold a column's (values, def-levels) pages into a nullable 'Column'.
-
-Pre-allocates the output buffer and a valid-mask vector of @totalRows@, then
-scatters values inline during a single streaming left fold ('PageFold').
-
-A 'hasNull' flag is accumulated during the scatter so the
-'buildBitmapFromValid' call is skipped entirely when all values are present.
--}
+-- | Fold a column's value and def-level pages into a nullable 'Column'.
 {-# INLINEABLE foldNullable #-}
 foldNullable ::
     forall m a.
@@ -232,13 +208,7 @@
     PageFold m VB.Vector a ->
     m Column
 foldNullable maxDef totalRows runPages = do
-    -- null slots hold an error thunk, guarded by bitmap.
-    --
-    -- IMPORTANT: 'VBM.unsafeWrite' for boxed vectors stores a *pointer* to
-    -- the value without evaluating it, so unsupported-encoding error thunks
-    -- would be silently swallowed into the column data and only fire lazily
-    -- when user code reads a cell. The '!v' bang pattern forces each value
-    -- to WHNF before the write, surfacing decoder errors immediately.
+    -- The '!v' bang forces each value so decoder errors surface immediately.
     mvDat <-
         liftIO $ VBM.replicate totalRows (error "parquet: null slot accessed")
     mvValid <- liftIO (VUM.new totalRows :: IO (VUM.IOVector Word8))
@@ -303,17 +273,7 @@
             else return Nothing
     return (UnboxedColumn maybeBm dat)
 
-{- | Fold a stream of (values, def-levels, rep-levels) triples into a
-repeated (list) 'Column' using Dremel-style level stitching.
-
-The stitching function is selected by @maxRep@:
-
-  * @maxRep == 1@  →  'stitchList'   → @[Maybe [Maybe a]]@
-  * @maxRep == 2@  →  'stitchList2'  → @[Maybe [Maybe [Maybe a]]]@
-  * @maxRep >= 3@  →  'stitchList3'  → @[Maybe [Maybe [Maybe [Maybe a]]]]@
-
-Threshold formula: @defT_r = maxDef - 2 * (maxRep - r)@.
--}
+-- | Fold page triples into a repeated (list) 'Column' by level stitching.
 {-# INLINEABLE foldRepeated #-}
 foldRepeated ::
     forall m a.
@@ -363,10 +323,7 @@
             fromList (stitchList3 (maxDef - 4) (maxDef - 2) maxDef allReps allDefs allVals)
         _ -> fromList (stitchList maxDef allReps allDefs allVals)
 
-{- | Collect all of a column's page triples into a list, in page order. Used by
-the repeated/list folds, where Dremel level-stitching needs the full
-concatenated rep/def/value arrays at once (so streaming gives no benefit here).
--}
+-- | Collect all of a column's page triples into a list, in page order.
 collectPages ::
     (Monad m) =>
     PageFold m v a ->
diff --git a/src/DataFrame/IO/Parquet/Writer.hs b/src/DataFrame/IO/Parquet/Writer.hs
--- a/src/DataFrame/IO/Parquet/Writer.hs
+++ b/src/DataFrame/IO/Parquet/Writer.hs
@@ -12,12 +12,15 @@
     nativeTypeKeyValues,
 ) where
 
+import Control.Exception (evaluate)
 import Control.Monad (forM_, unless, when)
+import Control.Monad.ST (RealWorld, ST, stToIO)
 import qualified Data.ByteString as BS
 import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
 import Data.Int (Int64)
 import Data.Maybe (fromJust)
 import Data.Primitive.ByteArray (getSizeofMutableByteArray)
+import Data.STRef (STRef, modifySTRef', newSTRef, readSTRef, writeSTRef)
 import qualified Data.Text as T
 import qualified Data.Vector as VB
 import DataFrame.IO.Parquet.Thrift hiding (schema)
@@ -47,12 +50,12 @@
     WritableBinaryHandle,
     atomicallyWriteFile,
     bufferResidency,
-    bufferToByteString,
     ensureCapacity,
     flushBufferToBuffer,
     flushBufferToFile,
     mallocBuffer,
     resetPosition,
+    unsafeBufferView,
     withWritableBinaryFile,
     writeByteString,
     writeByteStringToFile,
@@ -73,27 +76,27 @@
 
 data ParquetWriterState = ParquetWriterState
     { outputFileHandle :: !WritableBinaryHandle
-    , columnChunks :: !(VB.Vector ColumnChunkState)
+    , columnChunks :: !(VB.Vector (ColumnChunkState RealWorld))
     , currentFileOffsetRef :: !(IORef Int64)
-    , scratchBuffer :: !MemoryBuffer
+    , scratchBuffer :: !(MemoryBuffer RealWorld)
     , rowGroupMetadataRef :: !(IORef [RowGroup])
     , rowNumberRef :: !(IORef Int)
     }
 
-data ColumnChunkState = ColumnChunkState
+data ColumnChunkState s = ColumnChunkState
     { columnName :: !T.Text
     , nullable :: !Bool
     , schema :: !SchemaElement
-    , encoder :: !Encoder
-    , buffer :: !MemoryBuffer
-    , uncompressedBufferSize :: !(IORef Int64)
-    , pageState :: !PageState
+    , encoder :: !(Encoder s)
+    , buffer :: !(MemoryBuffer s)
+    , uncompressedBufferSize :: !(STRef s Int64)
+    , pageState :: !(PageState s)
     }
 
-data PageState = PageState
-    { pageBuffer :: !MemoryBuffer
-    , definitionLevels :: !DefLevels
-    , currentRowCount :: !(IORef Int)
+data PageState s = PageState
+    { pageBuffer :: !(MemoryBuffer s)
+    , definitionLevels :: !(DefLevels s)
+    , currentRowCount :: !(STRef s Int)
     }
 
 writeParquet :: FilePath -> DataFrame -> IO ()
@@ -144,21 +147,27 @@
 
 -- | Write rows @[startRow, endRow)@ of the frame to a single Parquet file.
 writeShard ::
-    ParquetWriteOptions -> FilePath -> DataFrame -> Int -> Int -> IO ()
+    ParquetWriteOptions ->
+    FilePath ->
+    DataFrame ->
+    Int ->
+    Int ->
+    IO ()
 writeShard options path_ df startRow endRow = do
     let names = columnNames df
         shardRows = max 0 (endRow - startRow)
     columnChunks_ <-
-        VB.fromList
-            <$> mapM
-                ( \columnName_ ->
-                    initColumnChunkState
-                        options
-                        columnName_
-                        (fromJust (getColumn columnName_ df))
-                )
-                names
-    scratchBuffer_ <- mallocBuffer (max 1 options.pageSize)
+        stToIO $
+            VB.fromList
+                <$> mapM
+                    ( \columnName_ ->
+                        initColumnChunkState
+                            options
+                            columnName_
+                            (fromJust (getColumn columnName_ df))
+                    )
+                    names
+    scratchBuffer_ <- stToIO (mallocBuffer (max 1 options.pageSize))
     atomicallyWriteFile path_ $ \path -> withWritableBinaryFile path $ \output -> do
         writeByteStringToFile output magic
         currentFileOffsetRef_ <- newIORef 4
@@ -179,7 +188,9 @@
                 | rowNum >= batchEnd = pure ()
                 | otherwise = do
                     let count = min subBatch (batchEnd - rowNum)
-                    VB.forM_ columnChunks_ (writeRows options scratchBuffer_ rowNum count)
+                    VB.forM_ columnChunks_ $ \ccs -> do
+                        pageFull <- stToIO (writeRows options rowNum count ccs)
+                        when pageFull (flushPage options scratchBuffer_ ccs)
                     modifyIORef' rowNumberRef_ (+ count)
                     writeBatch (rowNum + count) batchEnd
             loop :: Int -> IO ()
@@ -188,8 +199,9 @@
                 | otherwise = do
                     let batchEnd = rowNum + min interval (endRow - rowNum)
                     writeBatch rowNum batchEnd
-                    size <- bufferedSize columnChunks_
-                    when (size >= options.rowGroupSize) (flushRowGroup options writerState)
+                    size <- stToIO (bufferedSize columnChunks_)
+                    when (size >= options.rowGroupSize) $
+                        flushRowGroup options writerState
                     loop batchEnd
         loop startRow
         flushRowGroup options writerState
@@ -215,26 +227,30 @@
     , Just col <- [getColumn name df]
     ]
 
+-- | Encode @count@ rows from @firstRow@ into the column's page.
 writeRows ::
-    ParquetWriteOptions -> MemoryBuffer -> Int -> Int -> ColumnChunkState -> IO ()
-writeRows options scratch firstRow count ccs = do
+    ParquetWriteOptions -> Int -> Int -> ColumnChunkState s -> ST s Bool
+writeRows options firstRow count ccs = do
     let page = ccs.pageState
         buf = page.pageBuffer
         encode = ccs.encoder.encodeValue
         dl = page.definitionLevels
         end = firstRow + count
 
-    pos0 <- readIORef buf.positionRef
+    pos0 <- readSTRef buf.positionRef
     let margin = options.pageSize
     arr0 <- ensureCapacity buf (pos0 + max margin (count * 64))
     size0 <- getSizeofMutableByteArray arr0
 
     let go !size !pos !row
-            | row >= end = writeIORef buf.positionRef pos
+            | row >= end = writeSTRef buf.positionRef pos
             | pos + margin > size = do
                 -- Rare: buffer nearly full, grow it
-                writeIORef buf.positionRef pos
-                arr' <- ensureCapacity buf (pos + max margin ((end - row) * 64))
+                writeSTRef buf.positionRef pos
+                arr' <-
+                    ensureCapacity
+                        buf
+                        (pos + max margin ((end - row) * 64))
                 size' <- getSizeofMutableByteArray arr'
                 go size' pos row
             | otherwise = do
@@ -246,30 +262,35 @@
     go size0 pos0 firstRow
 
     -- Batch bookkeeping: once per sub-batch instead of per value
-    modifyIORef' page.currentRowCount (+ count)
+    modifySTRef' page.currentRowCount (+ count)
     flushDef dl
     pageRes <- bufferResidency buf
     defRes <- bufferResidency dl.dlBuf
-    when
-        (pageRes + defRes >= options.pageSize)
-        (flushPage options scratch ccs)
+    pure (pageRes + defRes >= options.pageSize)
 
-flushPage :: ParquetWriteOptions -> MemoryBuffer -> ColumnChunkState -> IO ()
+flushPage ::
+    ParquetWriteOptions ->
+    MemoryBuffer RealWorld ->
+    ColumnChunkState RealWorld ->
+    IO ()
 flushPage options scratch columnChunkState = do
     let page = columnChunkState.pageState
-    numPageRows <- readIORef page.currentRowCount
+    numPageRows <- stToIO (readSTRef page.currentRowCount)
     when (numPageRows > 0) $ do
-        pos <- readIORef page.pageBuffer.positionRef
-        pos' <- columnChunkState.encoder.finishValues page.pageBuffer pos
-        writeIORef page.pageBuffer.positionRef pos'
-        body <- assemblePageBody scratch columnChunkState
+        body <- stToIO $ do
+            pos <- readSTRef page.pageBuffer.positionRef
+            pos' <- columnChunkState.encoder.finishValues page.pageBuffer pos
+            writeSTRef page.pageBuffer.positionRef pos'
+            assemblePageBody scratch columnChunkState
         writeDataPage options.compressionCodec numPageRows body columnChunkState
-        resetPosition page.pageBuffer
-        resetPosition page.definitionLevels.dlBuf
-        resetPosition scratch
-        writeIORef page.currentRowCount 0
+        stToIO $ do
+            resetPosition page.pageBuffer
+            resetPosition page.definitionLevels.dlBuf
+            resetPosition scratch
+            writeSTRef page.currentRowCount 0
 
-assemblePageBody :: MemoryBuffer -> ColumnChunkState -> IO MemoryBuffer
+assemblePageBody ::
+    MemoryBuffer s -> ColumnChunkState s -> ST s (MemoryBuffer s)
 assemblePageBody scratch columnChunkState
     | not columnChunkState.nullable = pure columnChunkState.pageState.pageBuffer
     | otherwise = do
@@ -283,12 +304,19 @@
         pure scratch
 
 writeDataPage ::
-    CompressionCodec -> Int -> MemoryBuffer -> ColumnChunkState -> IO ()
+    CompressionCodec ->
+    Int ->
+    MemoryBuffer RealWorld ->
+    ColumnChunkState RealWorld ->
+    IO ()
 writeDataPage codec numPageRows body columnChunkState = do
-    uncompressedPageSize <- bufferResidency body
+    uncompressedPageSize <- stToIO (bufferResidency body)
     compressedBody <- case codec of
         UNCOMPRESSED _ -> pure Nothing
-        SNAPPY _ -> Just . Snappy.compress <$> bufferToByteString body
+        SNAPPY _ -> do
+            -- Compress now: the page buffer is reused for the next page.
+            page <- unsafeBufferView body
+            Just <$> evaluate (Snappy.compress page)
         other -> error ("writeParquet: unsupported codec " <> show other)
     let compressedPageSize = maybe uncompressedPageSize BS.length compressedBody
         headerBytes =
@@ -297,11 +325,12 @@
                 (mkDataPageHeader numPageRows uncompressedPageSize compressedPageSize)
     writeByteString columnChunkState.buffer headerBytes
     case compressedBody of
-        Nothing -> flushBufferToBuffer body columnChunkState.buffer
+        Nothing -> stToIO (flushBufferToBuffer body columnChunkState.buffer)
         Just bytes -> writeByteString columnChunkState.buffer bytes
-    modifyIORef'
-        columnChunkState.uncompressedBufferSize
-        (+ fromIntegral (BS.length headerBytes + uncompressedPageSize))
+    stToIO $
+        modifySTRef'
+            columnChunkState.uncompressedBufferSize
+            (+ fromIntegral (BS.length headerBytes + uncompressedPageSize))
 
 flushRowGroup :: ParquetWriteOptions -> ParquetWriterState -> IO ()
 flushRowGroup options writerState = do
@@ -314,13 +343,18 @@
             VB.foldM'
                 ( \(acc, totalCompressedSize, totalUncompressedSize) columnChunkState -> do
                     offset <- readIORef writerState.currentFileOffsetRef
-                    compressedSize <- bufferResidency columnChunkState.buffer
-                    uncompressedSize <- readIORef columnChunkState.uncompressedBufferSize
-                    flushBufferToFile writerState.outputFileHandle columnChunkState.buffer
+                    (compressedSize, uncompressedSize) <-
+                        stToIO $
+                            (,)
+                                <$> bufferResidency columnChunkState.buffer
+                                <*> readSTRef columnChunkState.uncompressedBufferSize
+                    flushBufferToFile
+                        writerState.outputFileHandle
+                        columnChunkState.buffer
                     writeIORef
                         writerState.currentFileOffsetRef
                         (offset + fromIntegral compressedSize)
-                    writeIORef columnChunkState.uncompressedBufferSize 0
+                    stToIO (writeSTRef columnChunkState.uncompressedBufferSize 0)
                     let columnChunk =
                             mkColumnChunk
                                 options.compressionCodec
@@ -349,20 +383,22 @@
             )
         writeIORef writerState.rowNumberRef 0
 
-bufferedSize :: VB.Vector ColumnChunkState -> IO Int
+bufferedSize :: VB.Vector (ColumnChunkState s) -> ST s Int
 bufferedSize =
     VB.foldM'
         ( \total columnChunkState -> do
             chunkSize <- bufferResidency columnChunkState.buffer
-            valuesSize <- bufferResidency columnChunkState.pageState.pageBuffer
+            valuesSize <-
+                bufferResidency columnChunkState.pageState.pageBuffer
             defLevelsSize <-
-                bufferResidency columnChunkState.pageState.definitionLevels.dlBuf
+                bufferResidency
+                    columnChunkState.pageState.definitionLevels.dlBuf
             pure (total + chunkSize + valuesSize + defLevelsSize)
         )
         0
 
 initColumnChunkState ::
-    ParquetWriteOptions -> T.Text -> Column -> IO ColumnChunkState
+    ParquetWriteOptions -> T.Text -> Column -> ST s (ColumnChunkState s)
 initColumnChunkState options columnName_ column = do
     encoder_ <- buildEncoder column
     let nullable_ = hasMissing column
@@ -374,20 +410,9 @@
                 encoder_.convertedType
                 encoder_.logicalType
         bufferSize = max 1 options.pageSize
-    -- ColumnChunk Buffers start at page size and grow to their
-    -- actual size over the course of building out the first row
-    -- group.
-    -- Each column chunk in a row group must have the same number
-    -- of rows, but each column chunk is liable to fit the same
-    -- number of rows in varying amounts of data depending on the
-    -- encoding and the compression characteristics of the data.
-    -- So the optimal buffer size of each column chunk is liable
-    -- to vary
-    -- As a result while one specific column chunk in a row group
-    -- is likely to hit the page limit, the others are liable to be
-    -- much smaller than the limit.
+    -- Buffers start at page size and grow, as chunk sizes vary by encoding.
     buffer_ <- mallocBuffer bufferSize
-    uncompressedBufferSize_ <- newIORef 0
+    uncompressedBufferSize_ <- newSTRef 0
     pageState_ <- initPageState bufferSize
     pure
         ColumnChunkState
@@ -400,11 +425,11 @@
             , pageState = pageState_
             }
 
-initPageState :: Int -> IO PageState
+initPageState :: Int -> ST s (PageState s)
 initPageState bufferSize = do
     pageBuffer_ <- mallocBuffer bufferSize
     definitionLevels_ <- newDefLevels
-    currentRowCount_ <- newIORef 0
+    currentRowCount_ <- newSTRef 0
     pure
         PageState
             { pageBuffer = pageBuffer_
diff --git a/src/DataFrame/IO/Parquet/Writer/DefLevels.hs b/src/DataFrame/IO/Parquet/Writer/DefLevels.hs
--- a/src/DataFrame/IO/Parquet/Writer/DefLevels.hs
+++ b/src/DataFrame/IO/Parquet/Writer/DefLevels.hs
@@ -8,51 +8,52 @@
 ) where
 
 import Control.Monad (when)
+import Control.Monad.ST (ST)
 import Data.Bits (shiftL, shiftR, (.&.), (.|.))
-import Data.IORef (IORef, newIORef, readIORef, writeIORef)
+import Data.STRef (STRef, newSTRef, readSTRef, writeSTRef)
 import Data.Word (Word64)
 import DataFrame.IO.Utils.RandomAccess (MemoryBuffer, mallocBuffer, writeWord8)
 
-data DefLevels = DefLevels
-    { dlBuf :: !MemoryBuffer
-    , dlValue :: !(IORef Int)
-    , dlCount :: !(IORef Int)
+data DefLevels s = DefLevels
+    { dlBuf :: !(MemoryBuffer s)
+    , dlValue :: !(STRef s Int)
+    , dlCount :: !(STRef s Int)
     }
 
-newDefLevels :: IO DefLevels
-newDefLevels = DefLevels <$> mallocBuffer 64 <*> newIORef 0 <*> newIORef 0
+newDefLevels :: ST s (DefLevels s)
+newDefLevels = DefLevels <$> mallocBuffer 64 <*> newSTRef 0 <*> newSTRef 0
 
-pushDef :: DefLevels -> Int -> IO ()
+pushDef :: DefLevels s -> Int -> ST s ()
 pushDef dl value = do
-    count <- readIORef dl.dlCount
+    count <- readSTRef dl.dlCount
     if count == 0
-        then writeIORef dl.dlValue value >> writeIORef dl.dlCount 1
+        then writeSTRef dl.dlValue value >> writeSTRef dl.dlCount 1
         else do
-            current <- readIORef dl.dlValue
+            current <- readSTRef dl.dlValue
             if current == value
-                then writeIORef dl.dlCount (count + 1)
+                then writeSTRef dl.dlCount (count + 1)
                 else do
                     writeDefRun dl current count
-                    writeIORef dl.dlValue value
-                    writeIORef dl.dlCount 1
+                    writeSTRef dl.dlValue value
+                    writeSTRef dl.dlCount 1
 {-# INLINE pushDef #-}
 
-flushDef :: DefLevels -> IO ()
+flushDef :: DefLevels s -> ST s ()
 flushDef dl = do
-    count <- readIORef dl.dlCount
+    count <- readSTRef dl.dlCount
     when (count > 0) $ do
-        value <- readIORef dl.dlValue
+        value <- readSTRef dl.dlValue
         writeDefRun dl value count
-    writeIORef dl.dlCount 0
+    writeSTRef dl.dlCount 0
 {-# INLINE flushDef #-}
 
-writeDefRun :: DefLevels -> Int -> Int -> IO ()
+writeDefRun :: DefLevels s -> Int -> Int -> ST s ()
 writeDefRun dl value count = do
     writeLeb128 dl.dlBuf (fromIntegral (count `shiftL` 1))
     writeWord8 dl.dlBuf (fromIntegral value)
 {-# INLINE writeDefRun #-}
 
-writeLeb128 :: MemoryBuffer -> Word64 -> IO ()
+writeLeb128 :: MemoryBuffer s -> Word64 -> ST s ()
 writeLeb128 buffer value
     | value < 0x80 = writeWord8 buffer (fromIntegral value)
     | otherwise = do
diff --git a/src/DataFrame/IO/Parquet/Writer/Encoder.hs b/src/DataFrame/IO/Parquet/Writer/Encoder.hs
--- a/src/DataFrame/IO/Parquet/Writer/Encoder.hs
+++ b/src/DataFrame/IO/Parquet/Writer/Encoder.hs
@@ -10,14 +10,11 @@
     buildEncoder,
 ) where
 
-import Control.Monad.ST (stToIO)
+import Control.Monad.ST (ST)
 import Data.Bits (shiftL, (.|.))
-import Data.IORef (newIORef, readIORef, writeIORef)
 import Data.Int (Int32, Int64)
-import Data.Primitive.ByteArray (
-    withMutableByteArrayContents,
-    writeByteArray,
- )
+import Data.Primitive.ByteArray (withMutableByteArrayContents, writeByteArray)
+import Data.STRef (newSTRef, readSTRef, writeSTRef)
 import qualified Data.Text as T
 import qualified Data.Text.Array as TA
 import Data.Text.Internal (Text (Text))
@@ -55,153 +52,136 @@
 import Pinch (enum, putField)
 import Type.Reflection (typeRep)
 
-data Encoder = Encoder
+data Encoder s = Encoder
     { encType :: !ThriftType
     , convertedType :: !(Maybe ConvertedType)
     , logicalType :: !(Maybe LogicalType)
-    , encodeValue :: !(MemoryBuffer -> Int -> Int -> IO (Int, Bool))
-    , finishValues :: !(MemoryBuffer -> Int -> IO Int)
+    , encodeValue :: !(MemoryBuffer s -> Int -> Int -> ST s (Int, Bool))
+    , finishValues :: !(MemoryBuffer s -> Int -> ST s Int)
     }
 
-buildEncoder :: Column -> IO Encoder
+buildEncoder :: Column -> ST s (Encoder s)
 buildEncoder col
     | hasElemType @Int32 col =
         pure $
-            scalarEncoder @Int32
+            scalarEncoder
                 (INT32 enum)
                 Nothing
                 Nothing
-                (\buffer pos v -> writeWord32At buffer pos (fromIntegral v) >> pure (pos + 4))
-                col
+                ( unboxedColumnWriter @Int32
+                    col
+                    (\buffer pos v -> write32 buffer pos (fromIntegral v))
+                )
     | hasElemType @Int64 col =
         pure $
-            scalarEncoder @Int64
+            scalarEncoder
                 (INT64 enum)
                 Nothing
                 Nothing
-                (\buffer pos v -> writeWord64At buffer pos (fromIntegral v) >> pure (pos + 8))
-                col
-    -- Ints in GHC can be 32 bit or 64 bit integers depending on the
-    -- underlying computers architecture. So we'll do 64bit integers
-    -- to cover all our bases
+                ( unboxedColumnWriter @Int64
+                    col
+                    (\buffer pos v -> write64 buffer pos (fromIntegral v))
+                )
+    -- Int is 32 or 64 bit by platform, so always write 64-bit integers.
     | hasElemType @Int col =
         pure $
-            scalarEncoder @Int
+            scalarEncoder
                 (INT64 enum)
                 Nothing
                 Nothing
-                (\buffer pos v -> writeWord64At buffer pos (fromIntegral v) >> pure (pos + 8))
-                col
+                ( unboxedColumnWriter @Int
+                    col
+                    (\buffer pos v -> write64 buffer pos (fromIntegral v))
+                )
     | hasElemType @Integer col =
         pure $
-            scalarEncoder @Integer
+            scalarEncoder
                 (INT64 enum)
                 Nothing
                 Nothing
-                writeInteger64At
-                col
+                (columnWriter @Integer col writeInteger64At)
     | hasElemType @Float col =
         pure $
-            scalarEncoder @Float
+            scalarEncoder
                 (FLOAT enum)
                 Nothing
                 Nothing
-                ( \buffer pos v -> writeWord32At buffer pos (castFloatToWord32 v) >> pure (pos + 4)
+                ( unboxedColumnWriter @Float
+                    col
+                    (\buffer pos v -> write32 buffer pos (castFloatToWord32 v))
                 )
-                col
     | hasElemType @Double col =
         pure $
-            scalarEncoder @Double
+            scalarEncoder
                 (DOUBLE enum)
                 Nothing
                 Nothing
-                ( \buffer pos v -> writeWord64At buffer pos (castDoubleToWord64 v) >> pure (pos + 8)
+                ( unboxedColumnWriter @Double
+                    col
+                    (\buffer pos v -> write64 buffer pos (castDoubleToWord64 v))
                 )
-                col
     | hasElemType @Bool col = boolEncoder col
     | hasElemType @T.Text col = pure (textEncoder col)
     | hasElemType @UTCTime col = pure (timestampEncoder col)
     | otherwise =
         error ("writeParquet: unsupported column type " <> columnTypeString col)
+  where
+    write32 buffer pos value = do
+        writeWord32At buffer pos value
+        pure (pos + 4)
+    write64 buffer pos value = do
+        writeWord64At buffer pos value
+        pure (pos + 8)
 
+type ColumnWriter s = MemoryBuffer s -> Int -> Int -> ST s (Int, Bool)
+
 scalarEncoder ::
-    forall a.
-    (Columnable a) =>
     ThriftType ->
     Maybe ConvertedType ->
     Maybe LogicalType ->
-    (MemoryBuffer -> Int -> a -> IO Int) ->
-    Column ->
-    Encoder
-scalarEncoder tt conv logical writePrim col =
-    Encoder tt conv logical (columnWriter @a col writePrim) (\_ pos -> pure pos)
-{-# INLINEABLE scalarEncoder #-}
-{-# SPECIALIZE scalarEncoder ::
-    ThriftType ->
-    Maybe ConvertedType ->
-    Maybe LogicalType ->
-    (MemoryBuffer -> Int -> Int32 -> IO Int) ->
-    Column ->
-    Encoder
-    #-}
-{-# SPECIALIZE scalarEncoder ::
-    ThriftType ->
-    Maybe ConvertedType ->
-    Maybe LogicalType ->
-    (MemoryBuffer -> Int -> Int64 -> IO Int) ->
-    Column ->
-    Encoder
-    #-}
-{-# SPECIALIZE scalarEncoder ::
-    ThriftType ->
-    Maybe ConvertedType ->
-    Maybe LogicalType ->
-    (MemoryBuffer -> Int -> Float -> IO Int) ->
-    Column ->
-    Encoder
-    #-}
-{-# SPECIALIZE scalarEncoder ::
-    ThriftType ->
-    Maybe ConvertedType ->
-    Maybe LogicalType ->
-    (MemoryBuffer -> Int -> Double -> IO Int) ->
-    Column ->
-    Encoder
-    #-}
-{-# SPECIALIZE scalarEncoder ::
-    ThriftType ->
-    Maybe ConvertedType ->
-    Maybe LogicalType ->
-    (MemoryBuffer -> Int -> Int -> IO Int) ->
+    ColumnWriter s ->
+    Encoder s
+scalarEncoder tt conv logical encode =
+    Encoder tt conv logical encode (\_ pos -> pure pos)
+
+-- | Encode a column whose element type has no 'VU.Unbox' instance.
+columnWriter ::
+    forall a s.
+    (Columnable a) =>
     Column ->
-    Encoder
-    #-}
-{-# SPECIALIZE scalarEncoder ::
-    ThriftType ->
-    Maybe ConvertedType ->
-    Maybe LogicalType ->
-    (MemoryBuffer -> Int -> Integer -> IO Int) ->
+    (MemoryBuffer s -> Int -> a -> ST s Int) ->
+    ColumnWriter s
+columnWriter = columnWriterWith Nothing
+{-# INLINE columnWriter #-}
+
+-- | Encode a column whose element type has a 'VU.Unbox' instance.
+unboxedColumnWriter ::
+    forall a s.
+    (Columnable a, VU.Unbox a) =>
     Column ->
-    Encoder
-    #-}
+    (MemoryBuffer s -> Int -> a -> ST s Int) ->
+    ColumnWriter s
+unboxedColumnWriter = columnWriterWith (Just VU.unsafeIndex)
+{-# INLINE unboxedColumnWriter #-}
 
-columnWriter ::
-    forall a.
+columnWriterWith ::
+    forall a s.
     (Columnable a) =>
+    Maybe (VU.Vector a -> Int -> a) ->
     Column ->
-    (MemoryBuffer -> Int -> a -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-columnWriter col writePrim = case col of
+    (MemoryBuffer s -> Int -> a -> ST s Int) ->
+    ColumnWriter s
+columnWriterWith unboxedIndex col writePrim = case col of
     BoxedColumn bitmap (values :: VB.Vector b) ->
         case testEquality (typeRep @a) (typeRep @b) of
             Just Refl -> writeFrom bitmap (VB.unsafeIndex values)
             Nothing -> mismatch
     UnboxedColumn bitmap (values :: VU.Vector b) ->
         case testEquality (typeRep @a) (typeRep @b) of
-            Just Refl -> writeFrom bitmap (VU.unsafeIndex values)
+            Just Refl ->
+                writeFrom
+                    bitmap
+                    (maybe (VU.unsafeIndex values) ($ values) unboxedIndex)
             Nothing -> mismatch
     _ -> mismatch
   where
@@ -213,114 +193,56 @@
     mismatch =
         error
             ("writeParquet: incompatible column representation for " <> columnTypeString col)
-{-# INLINEABLE columnWriter #-}
-{-# SPECIALIZE columnWriter ::
-    Column ->
-    (MemoryBuffer -> Int -> Int32 -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-    #-}
-{-# SPECIALIZE columnWriter ::
-    Column ->
-    (MemoryBuffer -> Int -> Int64 -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-    #-}
-{-# SPECIALIZE columnWriter ::
-    Column ->
-    (MemoryBuffer -> Int -> Float -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-    #-}
-{-# SPECIALIZE columnWriter ::
-    Column ->
-    (MemoryBuffer -> Int -> Double -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-    #-}
-{-# SPECIALIZE columnWriter ::
-    Column ->
-    (MemoryBuffer -> Int -> Bool -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-    #-}
-{-# SPECIALIZE columnWriter ::
-    Column ->
-    (MemoryBuffer -> Int -> UTCTime -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-    #-}
-{-# SPECIALIZE columnWriter ::
-    Column ->
-    (MemoryBuffer -> Int -> Int -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-    #-}
-{-# SPECIALIZE columnWriter ::
-    Column ->
-    (MemoryBuffer -> Int -> Integer -> IO Int) ->
-    MemoryBuffer ->
-    Int ->
-    Int ->
-    IO (Int, Bool)
-    #-}
+{-# INLINE columnWriterWith #-}
 
 isPresent :: Maybe Bitmap -> Int -> Bool
 isPresent Nothing _ = True
 isPresent (Just bitmap) row = bitmapTestBit bitmap row
 {-# INLINE isPresent #-}
 
-boolEncoder :: Column -> IO Encoder
+boolEncoder :: Column -> ST s (Encoder s)
 boolEncoder col = do
-    bitsRef <- newIORef (0 :: Word8)
-    countRef <- newIORef (0 :: Int)
+    bitsRef <- newSTRef (0 :: Word8)
+    countRef <- newSTRef (0 :: Int)
     let addBit buffer pos value = do
-            bits <- readIORef bitsRef
-            count <- readIORef countRef
+            bits <- readSTRef bitsRef
+            count <- readSTRef countRef
             let bits' = if value then bits .|. ((1 :: Word8) `shiftL` count) else bits
                 count' = count + 1
             if count' == 8
                 then do
-                    arr <- readIORef buffer.arrayRef
+                    arr <- readSTRef buffer.arrayRef
                     writeByteArray arr pos bits'
-                    writeIORef bitsRef 0
-                    writeIORef countRef 0
+                    writeSTRef bitsRef 0
+                    writeSTRef countRef 0
                     pure (pos + 1)
                 else do
-                    writeIORef bitsRef bits'
-                    writeIORef countRef count'
+                    writeSTRef bitsRef bits'
+                    writeSTRef countRef count'
                     pure pos
         finish buffer pos = do
-            count <- readIORef countRef
+            count <- readSTRef countRef
             pos' <-
                 if count > 0
                     then do
-                        bits <- readIORef bitsRef
-                        arr <- readIORef buffer.arrayRef
+                        bits <- readSTRef bitsRef
+                        arr <- readSTRef buffer.arrayRef
                         writeByteArray arr pos bits
                         pure (pos + 1)
                     else pure pos
-            writeIORef bitsRef 0
-            writeIORef countRef 0
+            writeSTRef bitsRef 0
+            writeSTRef countRef 0
             pure pos'
     pure
-        (Encoder (BOOLEAN enum) Nothing Nothing (columnWriter @Bool col addBit) finish)
+        ( Encoder
+            (BOOLEAN enum)
+            Nothing
+            Nothing
+            (unboxedColumnWriter @Bool col addBit)
+            finish
+        )
 
-textEncoder :: Column -> Encoder
+textEncoder :: Column -> Encoder s
 textEncoder col =
     Encoder
         (BYTE_ARRAY enum)
@@ -351,18 +273,18 @@
             pure (pos', True)
         | otherwise = pure (pos, False)
     writeTextSlice buffer pos bytes offset count = do
-        writeIORef buffer.positionRef pos
+        writeSTRef buffer.positionRef pos
         _ <- ensureCapacity buffer (pos + 4 + count)
         writeWord32At buffer pos (fromIntegral count)
-        arr <- readIORef buffer.arrayRef
+        arr <- readSTRef buffer.arrayRef
         withMutableByteArrayContents arr $ \ptr ->
-            stToIO (TA.copyToPointer bytes offset (ptr `plusPtr` (pos + 4)) count)
+            TA.copyToPointer bytes offset (ptr `plusPtr` (pos + 4)) count
         pure (pos + 4 + count)
     mismatch =
         error
             ("writeParquet: incompatible text representation for " <> columnTypeString col)
 
-timestampEncoder :: Column -> Encoder
+timestampEncoder :: Column -> Encoder s
 timestampEncoder col =
     Encoder
         (INT64 enum)
diff --git a/src/DataFrame/IO/Parquet/Writer/Metadata.hs b/src/DataFrame/IO/Parquet/Writer/Metadata.hs
--- a/src/DataFrame/IO/Parquet/Writer/Metadata.hs
+++ b/src/DataFrame/IO/Parquet/Writer/Metadata.hs
@@ -10,6 +10,7 @@
     magic,
 ) where
 
+import Control.Monad.ST (stToIO)
 import qualified Data.ByteString as BS
 import Data.Int (Int64)
 import qualified Data.Text as T
@@ -165,9 +166,9 @@
                 , footer_signing_key_metadata = putField Nothing
                 }
         footer = Pinch.encode Pinch.compactProtocol metadata
-    buffer <- mallocBuffer (BS.length footer + 8)
+    buffer <- stToIO (mallocBuffer (BS.length footer + 8))
     writeByteString buffer footer
-    writeWord32LE buffer (fromIntegral (BS.length footer))
+    stToIO (writeWord32LE buffer (fromIntegral (BS.length footer)))
     writeByteString buffer magic
     flushBufferToFile output buffer
 
diff --git a/src/DataFrame/IO/Utils/RandomAccess.hs b/src/DataFrame/IO/Utils/RandomAccess.hs
--- a/src/DataFrame/IO/Utils/RandomAccess.hs
+++ b/src/DataFrame/IO/Utils/RandomAccess.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MagicHash #-}
 {-# LANGUAGE OverloadedRecordDot #-}
 
 module DataFrame.IO.Utils.RandomAccess (
@@ -8,6 +9,7 @@
     ReaderIO (runReaderIO),
     LocalFile,
     MMappedFile,
+    ChunkBuffer (..),
     unsafeToByteString,
     WritableBinaryHandle,
     openWritableBinaryFile,
@@ -29,31 +31,31 @@
     writeDoubleLE,
     bufferResidency,
     bufferToByteString,
+    unsafeBufferView,
     flushBufferToBuffer,
     resetPosition,
     flushBufferToFile,
     writeByteStringToFile,
 ) where
 
-import Control.Exception (bracket, bracketOnError, finally)
+import Control.Exception (bracket, bracketOnError, finally, throw)
 import Control.Monad (when)
 import Control.Monad.IO.Class (MonadIO (..))
-import Control.Monad.Primitive (RealWorld)
-import Control.Monad.ST (stToIO)
+import Control.Monad.ST (RealWorld, ST, stToIO)
 import Data.Bits (shiftR)
-import qualified Data.ByteString as BS
-import Data.ByteString.Internal (ByteString (PS), create)
+import Data.ByteString.Internal (ByteString (BS, PS), create)
 import qualified Data.ByteString.Unsafe as BU
-import Data.IORef (IORef, newIORef, readIORef, writeIORef)
 import Data.Int (Int64)
 import Data.Primitive.ByteArray (
-    MutableByteArray,
+    MutableByteArray (..),
     copyMutableByteArray,
     getSizeofMutableByteArray,
+    mutableByteArrayContents,
     newPinnedByteArray,
     withMutableByteArrayContents,
     writeByteArray,
  )
+import Data.STRef (STRef, newSTRef, readSTRef, writeSTRef)
 import qualified Data.Text.Array as TA
 import qualified Data.Vector.Storable as VS
 import Data.Word (Word32, Word64, Word8)
@@ -65,6 +67,8 @@
  )
 import Foreign (castForeignPtr, castPtr, copyBytes, plusPtr)
 import GHC.Float (castDoubleToWord64, castFloatToWord32)
+import GHC.ForeignPtr (ForeignPtr (..), ForeignPtrContents (PlainPtr))
+import GHC.Ptr (Ptr (..))
 import System.Directory (copyPermissions, doesFileExist, removeFile, renameFile)
 import System.FilePath (takeDirectory)
 import System.IO (
@@ -120,6 +124,14 @@
         fGet handle length'
     readSuffix n = ReaderIO (readLastBytes $ fromIntegral n)
 
+-- | Bytes of one column chunk, already read; @base@ is their file offset.
+data ChunkBuffer = ChunkBuffer !Integer !ByteString
+
+instance RandomAccess (ReaderIO ChunkBuffer) where
+    readBytes (Range offset' length') = ReaderIO $ \(ChunkBuffer base bs) ->
+        pure (BU.unsafeTake length' (BU.unsafeDrop (fromInteger (offset' - base)) bs))
+    readSuffix _ = ReaderIO $ \_ -> error "ChunkBuffer: no file footer"
+
 type MMappedFile = ReaderIO (VS.Vector Word8)
 
 -- The instance exists but we don't have the means to mmap the file currently
@@ -139,20 +151,7 @@
   where
     (ptr, offset', len) = VS.unsafeToForeignPtr v
 
--- Writer Buffer -----------------------------------------------------------------
-
--- Refer to DataFrame.IO.Parquet.Writer for a justification of what we're doing here
--- There's some overlap here with what's going on in Seeking.hs, so, if this bothers
--- us, eventually someone will have to come back and reconcile the writer buffer
--- approach with the reader oriented patterns in Seeking.hs.
---
--- We're using MutableByteArrays here for convenience and because we don't need
--- the more powerful abstractions vector provides (which uses ByteArrays internally)
---
--- since we want to use hPutBuf, we're going to need a Ptr, which means are ByteArrya
--- must be pinned. Now growing pinned arrays can be problematic, but in the vast majority
--- of cases we shouldn't be growing more than once, if that. See the docs for
--- Data.Primitive.ByteArray.byteArrayContents.
+-- Writer buffers are pinned MutableByteArrays so hPutBuf can take a Ptr.
 
 newtype WritableBinaryHandle = WritableBinaryHandle {unHandle :: Handle}
 
@@ -163,7 +162,10 @@
     hSetBuffering h NoBuffering
     pure . WritableBinaryHandle $ h
 
-atomicallyWriteFile :: FilePath -> (FilePath -> IO a) -> IO a
+atomicallyWriteFile ::
+    FilePath ->
+    (FilePath -> IO a) ->
+    IO a
 atomicallyWriteFile path action =
     bracketOnError
         openAction
@@ -188,86 +190,76 @@
                 pure tmpFile
             )
 
-withWritableBinaryFile :: FilePath -> (WritableBinaryHandle -> IO a) -> IO a
+withWritableBinaryFile ::
+    FilePath ->
+    (WritableBinaryHandle -> IO a) ->
+    IO a
 withWritableBinaryFile filepath =
     bracket
         (openWritableBinaryFile filepath)
         (hClose . unHandle)
 
-data MemoryBuffer = MemoryBuffer
-    { arrayRef :: !(IORef (MutableByteArray RealWorld))
-    , positionRef :: !(IORef Int)
+data MemoryBuffer s = MemoryBuffer
+    { arrayRef :: !(STRef s (MutableByteArray s))
+    , positionRef :: !(STRef s Int)
     }
 
-mallocBuffer :: Int -> IO MemoryBuffer
+mallocBuffer :: Int -> ST s (MemoryBuffer s)
 mallocBuffer capacity
-    | capacity < 0 = ioError $ userError "mallocBuffer: negative capacity"
+    | capacity < 0 = throw (userError "mallocBuffer: negative capacity")
     | otherwise = do
         array <- newPinnedByteArray capacity
-        MemoryBuffer <$> newIORef array <*> newIORef 0
+        MemoryBuffer <$> newSTRef array <*> newSTRef 0
 
--- We're using pinned ByteArrays so we must
--- not use the grow function brovided by Data.Primitive
--- instead we must alloocate a new pinned ByteArray.
--- We might have been worried about heap fragmentation
--- because a single pinned object in a 4KB GHC block can
--- keep the whole plock alive but oyr buffers will tend to
--- be much larger than that.
--- But the memory usage will temporarily spike to 2.5x the size of
--- the buffer, but it should be fine since the current writer is single threaded
--- and grows *should* be rare.
--- If it becomes an issue we should start tracking an array of pointers
--- to buffers intsead of replacing them wholesale so grwoing a buffer
--- is just a matter of adding a new buffer to the array (which we can
--- pre-allocate to three elements to begin with and grow it only on the
--- off chance that a buffer required more than three grows).
-ensureCapacity :: MemoryBuffer -> Int -> IO (MutableByteArray RealWorld)
+-- Pinned arrays cannot grow in place, so allocate a new one and copy.
+ensureCapacity :: MemoryBuffer s -> Int -> ST s (MutableByteArray s)
 ensureCapacity buffer needed = do
-    array <- readIORef buffer.arrayRef
+    array <- readSTRef buffer.arrayRef
     maxSize <- getSizeofMutableByteArray array
     if needed <= maxSize
         then pure array
         else do
-            position <- readIORef buffer.positionRef
+            position <- readSTRef buffer.positionRef
             grown <- newPinnedByteArray (needed + (needed `div` 2))
             copyMutableByteArray grown 0 array 0 position
-            writeIORef buffer.arrayRef grown
+            writeSTRef buffer.arrayRef grown
             pure grown
 {-# INLINE ensureCapacity #-}
 
-writeWord8 :: MemoryBuffer -> Word8 -> IO ()
+writeWord8 :: MemoryBuffer s -> Word8 -> ST s ()
 writeWord8 buffer b = do
-    position <- readIORef buffer.positionRef
+    position <- readSTRef buffer.positionRef
     array <- ensureCapacity buffer (position + 1)
     writeByteArray array position b
-    writeIORef buffer.positionRef (position + 1)
+    writeSTRef buffer.positionRef (position + 1)
 {-# INLINE writeWord8 #-}
 
-writeByteString :: MemoryBuffer -> ByteString -> IO ()
+-- Reading the ByteString's pointer needs IO, so this stays out of ST.
+writeByteString :: MemoryBuffer RealWorld -> ByteString -> IO ()
 writeByteString buffer bs =
     BU.unsafeUseAsCStringLen bs $ \(source, len) -> do
-        position <- readIORef buffer.positionRef
-        array <- ensureCapacity buffer (position + len)
+        position <- stToIO (readSTRef buffer.positionRef)
+        array <- stToIO (ensureCapacity buffer (position + len))
         withMutableByteArrayContents array $ \dst ->
             copyBytes (dst `plusPtr` position) (castPtr source) len
-        writeIORef buffer.positionRef (position + len)
+        stToIO (writeSTRef buffer.positionRef (position + len))
 {-# INLINE writeByteString #-}
 
-writeWord32LE :: MemoryBuffer -> Word32 -> IO ()
+writeWord32LE :: MemoryBuffer s -> Word32 -> ST s ()
 writeWord32LE buffer w = do
-    position <- readIORef buffer.positionRef
+    position <- readSTRef buffer.positionRef
     writeWord32At buffer position w
-    writeIORef buffer.positionRef (position + 4)
+    writeSTRef buffer.positionRef (position + 4)
 {-# INLINE writeWord32LE #-}
 
-writeWord64LE :: MemoryBuffer -> Word64 -> IO ()
+writeWord64LE :: MemoryBuffer s -> Word64 -> ST s ()
 writeWord64LE buffer w = do
-    position <- readIORef buffer.positionRef
+    position <- readSTRef buffer.positionRef
     writeWord64At buffer position w
-    writeIORef buffer.positionRef (position + 8)
+    writeSTRef buffer.positionRef (position + 8)
 {-# INLINE writeWord64LE #-}
 
-writeWord32At :: MemoryBuffer -> Int -> Word32 -> IO ()
+writeWord32At :: MemoryBuffer s -> Int -> Word32 -> ST s ()
 writeWord32At buffer position w = do
     array <- ensureCapacity buffer (position + 4)
     writeByteArray array position (fromIntegral w :: Word8)
@@ -276,7 +268,7 @@
     writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8)
 {-# INLINE writeWord32At #-}
 
-writeWord64At :: MemoryBuffer -> Int -> Word64 -> IO ()
+writeWord64At :: MemoryBuffer s -> Int -> Word64 -> ST s ()
 writeWord64At buffer position w = do
     array <- ensureCapacity buffer (position + 8)
     writeByteArray array position (fromIntegral w :: Word8)
@@ -289,14 +281,14 @@
     writeByteArray array (position + 7) (fromIntegral (w `shiftR` 56) :: Word8)
 {-# INLINE writeWord64At #-}
 
-writeInteger64 :: MemoryBuffer -> Integer -> IO ()
+writeInteger64 :: MemoryBuffer s -> Integer -> ST s ()
 writeInteger64 buffer value = do
-    position <- readIORef buffer.positionRef
+    position <- readSTRef buffer.positionRef
     newPosition <- writeInteger64At buffer position value
-    writeIORef buffer.positionRef newPosition
+    writeSTRef buffer.positionRef newPosition
 {-# INLINE writeInteger64 #-}
 
-writeInteger64At :: MemoryBuffer -> Int -> Integer -> IO Int
+writeInteger64At :: MemoryBuffer s -> Int -> Integer -> ST s Int
 writeInteger64At buffer position value
     | value < toInteger (minBound :: Int64) = outOfRange
     | value > toInteger (maxBound :: Int64) = outOfRange
@@ -305,24 +297,24 @@
         pure (position + 8)
   where
     outOfRange =
-        ioError (userError "writeParquet: Integer value is outside the INT64 range")
+        throw (userError "writeParquet: Integer value is outside the INT64 range")
 {-# INLINE writeInteger64At #-}
 
-writeFloatLE :: MemoryBuffer -> Float -> IO ()
+writeFloatLE :: MemoryBuffer s -> Float -> ST s ()
 writeFloatLE buffer = writeWord32LE buffer . castFloatToWord32
 {-# INLINE writeFloatLE #-}
 
-writeDoubleLE :: MemoryBuffer -> Double -> IO ()
+writeDoubleLE :: MemoryBuffer s -> Double -> ST s ()
 writeDoubleLE buffer = writeWord64LE buffer . castDoubleToWord64
 {-# INLINE writeDoubleLE #-}
 
-flushBufferToBuffer :: MemoryBuffer -> MemoryBuffer -> IO ()
+flushBufferToBuffer :: MemoryBuffer s -> MemoryBuffer s -> ST s ()
 flushBufferToBuffer source destination
     | source.arrayRef == destination.arrayRef = pure ()
     | otherwise = do
-        sourceArray <- readIORef source.arrayRef
-        sourcePosition <- readIORef source.positionRef
-        destinationPosition <- readIORef destination.positionRef
+        sourceArray <- readSTRef source.arrayRef
+        sourcePosition <- readSTRef source.positionRef
+        destinationPosition <- readSTRef destination.positionRef
         destinationArray <-
             ensureCapacity destination (destinationPosition + sourcePosition)
         copyMutableByteArray
@@ -331,51 +323,39 @@
             sourceArray
             0
             sourcePosition
-        writeIORef destination.positionRef (destinationPosition + sourcePosition)
-        writeIORef source.positionRef 0
+        writeSTRef destination.positionRef (destinationPosition + sourcePosition)
+        writeSTRef source.positionRef 0
 {-# INLINE flushBufferToBuffer #-}
 
-bufferToByteString :: MemoryBuffer -> IO ByteString
+bufferToByteString :: MemoryBuffer RealWorld -> IO ByteString
 bufferToByteString buffer = do
-    array <- readIORef buffer.arrayRef
-    position <- readIORef buffer.positionRef
+    array <- stToIO (readSTRef buffer.arrayRef)
+    position <- stToIO (readSTRef buffer.positionRef)
     create position $ \dst ->
         withMutableByteArrayContents array $ \src ->
             copyBytes dst (castPtr src) position
 
-bufferResidency :: MemoryBuffer -> IO Int
-bufferResidency buffer = readIORef buffer.positionRef
+-- | The buffer's contents as a ByteString, without copying.
+unsafeBufferView :: MemoryBuffer RealWorld -> IO ByteString
+unsafeBufferView buffer = do
+    array@(MutableByteArray array#) <- stToIO (readSTRef buffer.arrayRef)
+    position <- stToIO (readSTRef buffer.positionRef)
+    let Ptr address# = mutableByteArrayContents array
+    pure (BS (ForeignPtr address# (PlainPtr array#)) position)
+
+bufferResidency :: MemoryBuffer s -> ST s Int
+bufferResidency buffer = readSTRef buffer.positionRef
 {-# INLINE bufferResidency #-}
 
-resetPosition :: MemoryBuffer -> IO ()
-resetPosition buffer = writeIORef buffer.positionRef 0
+resetPosition :: MemoryBuffer s -> ST s ()
+resetPosition buffer = writeSTRef buffer.positionRef 0
 {-# INLINE resetPosition #-}
 
--- I tested write speeds by doing (on Apple Silicon)
--- `dd if=/dev/zero of=test bs={$n}k oflag=direct conv=fdatasync
--- Results:
---
--- ```
---    | block size | data (GiB) |  time (s) | GiB/s |
---    |------------|------------|-----------|-------|
---    | 4k         |       4.00 |     2.371 |  1.69 |
---    | 8k         |       4.00 |     1.486 |  2.69 |
---    | 16k        |       4.00 |     1.045 |  3.83 |
---    | 32k        |       4.00 |     0.740 |  5.40 |
---    | 64k        |       4.00 |     0.675 |  5.92 |
---    | 128k       |       4.00 |     0.669 |  5.98 |
---    | 256k       |       4.00 |     0.664 |  6.03 |
---    | 512k       |       4.00 |     0.670 |  5.97 |
---    | 1024k      |       4.00 |     0.664 |  6.02 |
---    | 4096k      |       4.00 |     0.668 |  5.99 |
--- ```
--- So when writing to a file to minimize syscall overhead while
--- trying not to create dirty pages in the kernel page cache, we'll
--- be flushing in 256 KiB chunks.
-flushBufferToFile :: WritableBinaryHandle -> MemoryBuffer -> IO ()
+-- Flush in 256 KiB chunks; larger blocks gave no write speed in dd tests.
+flushBufferToFile :: WritableBinaryHandle -> MemoryBuffer RealWorld -> IO ()
 flushBufferToFile (WritableBinaryHandle h) buffer = do
-    array <- readIORef buffer.arrayRef
-    position <- readIORef buffer.positionRef
+    array <- stToIO (readSTRef buffer.arrayRef)
+    position <- stToIO (readSTRef buffer.positionRef)
     withMutableByteArrayContents array $ \ptr -> do
         let chunkSize = 262144
             go offset
@@ -385,7 +365,7 @@
                     hPutBuf h (ptr `plusPtr` offset) n
                     go (offset + n)
         go 0
-    writeIORef buffer.positionRef 0
+    stToIO (resetPosition buffer)
 
 writeByteStringToFile :: WritableBinaryHandle -> ByteString -> IO ()
 writeByteStringToFile (WritableBinaryHandle h) bs =
@@ -399,13 +379,13 @@
                     go (offset + n)
         go 0
 
-appendTextArraySlice :: MemoryBuffer -> TA.Array -> Int -> Int -> IO ()
+appendTextArraySlice :: MemoryBuffer s -> TA.Array -> Int -> Int -> ST s ()
 appendTextArraySlice buffer source offset count
-    | count < 0 = ioError $ userError "appendTextArraySlice: negative length"
+    | count < 0 = throw (userError "appendTextArraySlice: negative length")
     | otherwise = do
-        position <- readIORef buffer.positionRef
+        position <- readSTRef buffer.positionRef
         array <- ensureCapacity buffer (position + count)
         withMutableByteArrayContents array $ \destination ->
-            stToIO (TA.copyToPointer source offset (destination `plusPtr` position) count)
-        writeIORef buffer.positionRef (position + count)
+            TA.copyToPointer source offset (destination `plusPtr` position) count
+        writeSTRef buffer.positionRef (position + count)
 {-# INLINE appendTextArraySlice #-}
diff --git a/src/DataFrame/Typed/IO/Parquet.hs b/src/DataFrame/Typed/IO/Parquet.hs
--- a/src/DataFrame/Typed/IO/Parquet.hs
+++ b/src/DataFrame/Typed/IO/Parquet.hs
@@ -2,21 +2,7 @@
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
 
-{- | Typed Parquet reading.
-
-The reader validates the file against a type-level schema as it loads, supplied
-by type application:
-
-@
-type Trips = '[ '(\"id\", Int), '(\"fare\", Double)]
-
-trips <- readParquet \@Trips \"trips.parquet\"   -- IO (TypedDataFrame Trips)
-@
-
-'readParquet' (and 'readParquetWithOpts'\/'readParquetFiles') throw a
-'DataFrameException' on schema mismatch; 'readParquetWithError' returns the
-mismatch as an 'Either' instead.
--}
+-- | Typed Parquet reading, validated against a type-level schema.
 module DataFrame.Typed.IO.Parquet (
     readParquet,
     readParquetWithError,
@@ -34,10 +20,7 @@
 import DataFrame.Typed.Schema (KnownSchema, schemaColumnNames)
 import DataFrame.Typed.Types (TypedDataFrame)
 
-{- | Read only the columns @cols@ names, unless the caller selected columns
-themselves. Parquet supplies the element types, so the schema contributes the
-projection and the freeze checks the types.
--}
+-- | Read only the columns the schema names, unless the caller chose.
 schemaOptions ::
     forall cols. (KnownSchema cols) => ParquetReadOptions -> ParquetReadOptions
 schemaOptions opts =
@@ -45,27 +28,12 @@
         { selectedColumns = selectedColumns opts <|> Just (schemaColumnNames @cols)
         }
 
-{- | Read a Parquet file into a typed DataFrame, throwing on schema mismatch.
-Reads only the columns @cols@ names.
-
-==== __Example__
-@
-ghci> trips <- readParquet \@Trips \"trips.parquet\"
-@
--}
+-- | Read a Parquet file into a typed DataFrame, throwing on mismatch.
 readParquet ::
     forall cols. (KnownSchema cols) => FilePath -> IO (TypedDataFrame cols)
 readParquet = readParquetWithOpts @cols defaultParquetReadOptions
 
-{- | Read a Parquet file, returning a descriptive error on schema mismatch or
-a missing column instead of throwing.
-
-==== __Example__
-@
-ghci> readParquetWithError \@Trips \"trips.parquet\"
-Right (TDF ...)
-@
--}
+-- | Read a Parquet file, returning an error on mismatch.
 readParquetWithError ::
     forall cols.
     (KnownSchema cols) =>
@@ -78,15 +46,7 @@
         Left (e :: SomeException) -> Left (T.pack (show e))
         Right df -> freezeWithError df
 
-{- | Read a Parquet file with custom options, throwing on schema mismatch. The
-schema still supplies the column selection; an explicit 'selectedColumns'
-takes precedence.
-
-==== __Example__
-@
-ghci> trips <- readParquetWithOpts \@Trips defaultParquetReadOptions{rowRange = Just (0, 10)} \"trips.parquet\"
-@
--}
+-- | Read a Parquet file with custom options, throwing on mismatch.
 readParquetWithOpts ::
     forall cols.
     (KnownSchema cols) =>
@@ -95,13 +55,7 @@
     Parquet.readParquetWithOpts (schemaOptions @cols opts) path
         >>= freezeOrThrow @cols
 
-{- | Read a directory\/glob of Parquet files into a typed DataFrame.
-
-==== __Example__
-@
-ghci> trips <- readParquetFiles \@Trips \".\/data\/trips\/*.parquet\"
-@
--}
+-- | Read a directory\/glob of Parquet files into a typed DataFrame.
 readParquetFiles ::
     forall cols. (KnownSchema cols) => FilePath -> IO (TypedDataFrame cols)
 readParquetFiles path =
diff --git a/stress/DataFrame10GB.hs b/stress/DataFrame10GB.hs
--- a/stress/DataFrame10GB.hs
+++ b/stress/DataFrame10GB.hs
@@ -19,7 +19,7 @@
 import DataFrame.Internal.Column (Column (..))
 import DataFrame.Internal.Column.Bitmap (Bitmap)
 import DataFrame.Internal.Data.PackedText (mkPackedContiguous32)
-import DataFrame.Internal.DataFrame (DataFrame, fromNamedColumns)
+import DataFrame.Internal.DataFrame (DataFrame, fromColumns)
 
 stressRows :: Int
 stressRows = 1_000_000
@@ -40,7 +40,7 @@
         * fromIntegral (2 * textBytesPerRow + 2 * (4 + 8 + 4 + 8))
 
 stressDataFrame :: DataFrame
-stressDataFrame = fromNamedColumns (concatMap columnGroup [0 .. stressGroups - 1])
+stressDataFrame = fromColumns (concatMap columnGroup [0 .. stressGroups - 1])
 
 columnGroup :: Int -> [(T.Text, Column)]
 columnGroup group =
diff --git a/tests/Main.hs b/tests/Main.hs
--- a/tests/Main.hs
+++ b/tests/Main.hs
@@ -1,4 +1,6 @@
+{-# LANGUAGE DataKinds #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
 
 -- | Tests for the writer-buffer logic in "DataFrame.IO.Utils.RandomAccess".
 module Main where
@@ -12,10 +14,18 @@
 import Test.HUnit
 
 import Control.Monad (unless)
+import Control.Monad.ST (stToIO)
 import Data.Int (Int32, Int64)
 import Data.Maybe (fromJust)
 import qualified Data.Text as T
-import DataFrame.IO.Parquet (readParquet, readParquetFiles)
+import DataFrame.IO.Parquet (
+    ParquetReadOptions (..),
+    defaultParquetReadOptions,
+    readParquet,
+    readParquetFiles,
+    readParquetWithOpts,
+    _readParquetWithOpts,
+ )
 import DataFrame.IO.Parquet.Writer (
     ParquetWriteOptions (..),
     defaultParquetWriteOptions,
@@ -27,21 +37,24 @@
 import DataFrame.Internal.DataFrame (
     DataFrame,
     columnNames,
-    fromNamedColumns,
+    fromColumns,
     getColumn,
  )
+import DataFrame.Operations.Subset (range, select)
+import qualified DataFrame.Typed.IO.Parquet as Typed
+import qualified DataFrame.Typed.Operations as DT
 import System.Directory (listDirectory)
 
 directWrites :: Test
 directWrites = TestCase $ do
-    buffer <- mallocBuffer 1
-    writeWord8 buffer 0xaa
-    writeWord32LE buffer 0x78563412
-    writeWord64LE buffer 0x0807060504030201
-    writeFloatLE buffer 1
-    writeDoubleLE buffer 1
+    buffer <- stToIO (mallocBuffer 1)
+    stToIO (writeWord8 buffer 0xaa)
+    stToIO (writeWord32LE buffer 0x78563412)
+    stToIO (writeWord64LE buffer 0x0807060504030201)
+    stToIO (writeFloatLE buffer 1)
+    stToIO (writeDoubleLE buffer 1)
     writeByteString buffer (BS.pack [0xfe, 0xff])
-    residency <- bufferResidency buffer
+    residency <- stToIO (bufferResidency buffer)
     bytes <- bufferToByteString buffer
     assertEqual "direct write residency" 27 residency
     assertEqual
@@ -80,20 +93,20 @@
 
 directBufferFlush :: Test
 directBufferFlush = TestCase $ do
-    source <- mallocBuffer 0
-    destination <- mallocBuffer 0
+    source <- stToIO (mallocBuffer 0)
+    destination <- stToIO (mallocBuffer 0)
     writeByteString destination (BS.pack [1, 2])
     writeByteString source (BS.pack [3, 4, 5])
-    flushBufferToBuffer source destination
-    sourceResidency <- bufferResidency source
+    stToIO (flushBufferToBuffer source destination)
+    sourceResidency <- stToIO (bufferResidency source)
     destinationBytes <- bufferToByteString destination
     assertEqual "source cleared" 0 sourceResidency
     assertEqual "destination appended" (BS.pack [1, 2, 3, 4, 5]) destinationBytes
-    flushBufferToBuffer destination destination
+    stToIO (flushBufferToBuffer destination destination)
     selfFlushedBytes <- bufferToByteString destination
     assertEqual "self flush is a no-op" destinationBytes selfFlushedBytes
-    resetPosition destination
-    destinationResidency <- bufferResidency destination
+    stToIO (resetPosition destination)
+    destinationResidency <- stToIO (bufferResidency destination)
     assertEqual "reset position" 0 destinationResidency
 
 directFileFlush :: Test
@@ -101,11 +114,11 @@
     withSystemTempDirectory "dfpq-buffer" $ \dir -> do
         let outPath = dir </> "out.bin"
             payload = BS.pack (take 300000 (cycle [0 .. 255]))
-        buffer <- mallocBuffer 1
+        buffer <- stToIO (mallocBuffer 1)
         writeByteString buffer payload
         withWritableBinaryFile outPath $ \output ->
             flushBufferToFile output buffer
-        residency <- bufferResidency buffer
+        residency <- stToIO (bufferResidency buffer)
         contents <- BS.readFile outPath
         assertEqual "source cleared after file flush" 0 residency
         assertEqual "large payload round-trips" payload contents
@@ -128,11 +141,25 @@
         df' <- readParquet out
         assertEqual label df df'
 
+-- Row groups of 512 rows give the parallel reader several tasks per column.
+parallelMatchesSequential :: String -> FilePath -> Test
+parallelMatchesSequential label path = TestCase $
+    withSystemTempDirectory "dfpq-parallel" $ \dir -> do
+        df <- readParquet path
+        let out = dir </> "out.parquet"
+        writeParquetWithOptions tinyWriteOpts out df
+        sequential <-
+            readParquetWithOpts defaultParquetReadOptions{parallel = False} out
+        parallelRead <-
+            readParquetWithOpts defaultParquetReadOptions{parallel = True} out
+        assertEqual (label ++ ": sequential") df sequential
+        assertEqual (label ++ ": parallel") df parallelRead
+
 writerRoundTripLargeText :: Test
 writerRoundTripLargeText = TestCase $
     withSystemTempDirectory "dfpq-writer" $ \dir -> do
         let payload = T.replicate 4096 "abcdefgh"
-            df = fromNamedColumns [("text", fromList [payload, "short"])]
+            df = fromColumns [("text", fromList [payload, "short"])]
             firstOut = dir </> "large-text-1.parquet"
             secondOut = dir </> "large-text-2.parquet"
         writeParquetWithOptions tinyWriteOpts firstOut df
@@ -142,9 +169,7 @@
         assertEqual "large text first round-trip" df firstRoundTrip
         assertEqual "large text second round-trip" df secondRoundTrip
 
-{- | Sharded writes: @maxRowsPerFile@ splits the frame across a glob pattern,
-and reading the shards back reproduces the original frame.
--}
+-- | Sharded writes split the frame and reading them back matches.
 writerRoundTripSharded :: String -> FilePath -> Int -> Int -> Test
 writerRoundTripSharded label path rowsPerFile expectedShards = TestCase $
     withSystemTempDirectory "dfpq-writer" $ \dir -> do
@@ -180,16 +205,12 @@
                 `catch` (\e -> True <$ evaluate (Prelude.length (show (e :: SomeException))))
         unless threw (assertFailure "expected an error for a path without '*'")
 
-{- | Parquet has one 64-bit integer type, so @Int@, @Int64@ and @Integer@
-columns all land in the file as @INT64@. The writer stamps the original
-Haskell type in the footer so the reader can put it back; without that, a
-CSV-inferred @Int@ column silently widens to @Int64@ on a round trip.
--}
+-- | Int, Int64 and Integer columns round-trip with their Haskell type.
 writerRoundTripNativeIntTypes :: Test
 writerRoundTripNativeIntTypes = TestCase $
     withSystemTempDirectory "dfpq-writer" $ \dir -> do
         let df =
-                fromNamedColumns
+                fromColumns
                     [ ("int", fromList [1 :: Int, 2, 3])
                     , ("int64", fromList [1 :: Int64, 2, 3])
                     , ("int32", fromList [1 :: Int32, 2, 3])
@@ -221,10 +242,45 @@
         , subBatchRows = 3
         }
 
+dottedColumnNames :: Test
+dottedColumnNames = TestCase $
+    withSystemTempDirectory "dfpq-dotted" $ \dir -> do
+        let df =
+                fromColumns
+                    [ ("sepal.length", fromList [5.1, 4.9, 4.7 :: Double])
+                    , ("length", fromList [1, 2, 3 :: Int])
+                    , ("variety", fromList ["a", "b", "c" :: T.Text])
+                    ]
+            out = dir </> "dotted.parquet"
+        writeParquet out df
+        whole <- readParquet out
+        assertEqual "names survive the round trip" (columnNames df) (columnNames whole)
+        projected <-
+            readParquetWithOpts
+                defaultParquetReadOptions{selectedColumns = Just ["sepal.length", "variety"]}
+                out
+        assertEqual
+            "projection keeps the dotted column"
+            ["sepal.length", "variety"]
+            (columnNames projected)
+        assertEqual
+            "projected rows match"
+            (select ["sepal.length", "variety"] df)
+            projected
+        leaf <-
+            readParquetWithOpts
+                defaultParquetReadOptions{selectedColumns = Just ["length"]}
+                out
+        assertEqual "a full name wins over a leaf alias" ["length"] (columnNames leaf)
+        typed <-
+            Typed.readParquet @'[ '("sepal.length", Double), '("variety", T.Text)] out
+        assertEqual "the typed reader projects a dotted schema" 3 (DT.nRows typed)
+
 tests :: Test
 tests =
     TestList
-        [ TestLabel "direct buffer writes" directWrites
+        [ TestLabel "dotted column names project by full name" dottedColumnNames
+        , TestLabel "direct buffer writes" directWrites
         , TestLabel "direct buffer-to-buffer flush" directBufferFlush
         , TestLabel "direct buffer-to-file flush" directFileFlush
         , TestLabel
@@ -243,6 +299,12 @@
             "writer roundtrip: alltypes_tiny_pages"
             (writerRoundTrip "alltypes_tiny_pages" "tests/data/alltypes_tiny_pages.parquet")
         , TestLabel
+            "parallel read matches sequential: transactions"
+            (parallelMatchesSequential "transactions" "tests/data/transactions.parquet")
+        , TestLabel
+            "parallel read matches sequential: mtcars"
+            (parallelMatchesSequential "mtcars" "tests/data/mtcars.parquet")
+        , TestLabel
             "writer roundtrip: transactions"
             (writerRoundTrip "transactions" "tests/data/transactions.parquet")
         , TestLabel
@@ -273,7 +335,75 @@
                 "tests/data/alltypes_plain.parquet"
             )
         , TestLabel "writer roundtrip: large text" writerRoundTripLargeText
+        , TestLabel "parallel read: many row groups" parallelReadRowGroups
+        , TestLabel "parallel read: row range skips row groups" parallelReadRowRange
+        , TestLabel "parallel read: widened integers" parallelReadWidenIntegers
         ]
+
+-- | A frame big enough for 'tinyWriteOpts' to split into many row groups.
+manyRowGroupsFrame :: DataFrame
+manyRowGroupsFrame =
+    fromColumns
+        [ ("n", fromList [i * 7 - 3000 | i <- [0 .. 2999 :: Int]])
+        , ("d", fromList [fromIntegral i / 8 :: Double | i <- [0 .. 2999 :: Int]])
+        ,
+            ( "t"
+            , fromList
+                [T.pack (show i) <> T.replicate (i `mod` 5) "é" | i <- [0 .. 2999 :: Int]]
+            )
+        ]
+
+withManyRowGroups :: (FilePath -> IO ()) -> IO ()
+withManyRowGroups body = withSystemTempDirectory "dfpq-rg" $ \dir -> do
+    let out = dir </> "rg.parquet"
+    writeParquetWithOptions tinyWriteOpts out manyRowGroupsFrame
+    body out
+
+-- | The parallel reader matches the sequential one and the source frame.
+parallelReadRowGroups :: Test
+parallelReadRowGroups = TestCase $ withManyRowGroups $ \out -> do
+    parallel <- readParquet out
+    sequential <- _readParquetWithOpts (Just True) defaultParquetReadOptions out
+    assertEqual "parallel vs source" manyRowGroupsFrame parallel
+    assertEqual "parallel vs sequential" sequential parallel
+    projected <-
+        readParquetWithOpts
+            defaultParquetReadOptions{selectedColumns = Just ["t", "n"]}
+            out
+    assertEqual "projection" (select ["t", "n"] manyRowGroupsFrame) projected
+
+-- | A row range only decodes the row groups it overlaps, and slices exactly.
+parallelReadRowRange :: Test
+parallelReadRowRange = TestCase $ withManyRowGroups $ \out -> do
+    let ranges = [(0, 5), (7, 1300), (1299, 1301), (2990, 3000), (2995, 4000), (100, 100)]
+    mapM_
+        ( \r -> do
+            got <- readParquetWithOpts defaultParquetReadOptions{rowRange = Just r} out
+            assertEqual ("range " ++ show r) (range r manyRowGroupsFrame) got
+        )
+        ranges
+
+-- | 'widenIntegers' turns Int32 and Int64 columns into Int on both read paths.
+parallelReadWidenIntegers :: Test
+parallelReadWidenIntegers = TestCase $ withSystemTempDirectory "dfpq-widen" $ \dir -> do
+    let n = 3000 :: Int
+        df =
+            fromColumns
+                [ ("i32", fromList [fromIntegral (i - 1500) :: Int32 | i <- [0 .. n - 1]])
+                , ("i64", fromList [fromIntegral i * 4000000000 :: Int64 | i <- [0 .. n - 1]])
+                ]
+        expected =
+            fromColumns
+                [ ("i32", fromList [i - 1500 | i <- [0 .. n - 1]])
+                , ("i64", fromList [i * 4000000000 | i <- [0 .. n - 1]])
+                ]
+        out = dir </> "ints.parquet"
+        opts = defaultParquetReadOptions{widenIntegers = True}
+    writeParquetWithOptions tinyWriteOpts out df
+    parallel <- readParquetWithOpts opts out
+    sequential <- _readParquetWithOpts (Just True) opts out
+    assertEqual "parallel" expected parallel
+    assertEqual "sequential" expected sequential
 
 main :: IO ()
 main = do
