Skip to content

wireform-csv

wireform-csv handles delimiter-separated tabular data in Haskell. Spreadsheet exports, log pipelines, and ETL jobs often arrive as CSV or TSV; this package parses them with RFC 4180 semantics, configurable delimiters, and SIMD-accelerated byte scanning. Derive ToCSV/FromCSV for typed rows, or use the streaming API when files are too large to load at once.

CapabilityWhy it matters
deriveCSV Template Haskell deriverMap header rows to Haskell records with wireform-derive annotations; Generic defaults work for simple cases
Configurable delimitersCSV (,), TSV (\t), pipe, or custom separators
Quoting and escapingRFC 4180 quoted fields with embedded delimiters
Streaming row callbacksdecodeStream processes one row at a time with constant memory
SIMD newline and delimiter scanWireform.FFI.findByteBS accelerates field boundaries
Header row handlingSkip or capture the first row via CSVConfig

Define a record, derive codecs with the Template Haskell deriver, and decode an entire file into a Vector of rows.

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
import GHC.Generics (Generic)
import Data.Text (Text)
import Data.Vector (Vector)
import Data.ByteString (ByteString)
import CSV.Class (ToCSV, FromCSV)
import CSV.Derive (deriveCSV)
import CSV.Decode (decodeRecords)
import CSV.Encode (encodeRecords)
import CSV.Value (defaultCSV)
data Row = Row
{ name :: !Text
, email :: !Text
, score :: !Int
} deriving stock (Generic)
$(deriveCSV ''Row)
loadRows :: ByteString -> Either String (Vector Row)
loadRows bs = decodeRecords defaultCSV bs

For simple cases with no wire-format customization, Generic defaults also work: add deriving Generic and declare empty instance ToCSV Row and instance FromCSV Row declarations.

Use defaultTSV from CSV.Value when the input is tab-separated.

Build a CSVConfig when the file uses non-standard separators or omits a header row.

import CSV.Value (CSVConfig(..), CSVDocument(..), defaultCSV)
import CSV.Decode (decode)
pipeConfig :: CSVConfig
pipeConfig = defaultCSV
{ csvDelimiter = '|'
, csvHasHeader = True
}
parsePipeFile :: ByteString -> Either String CSVDocument
parsePipeFile = decode pipeConfig

For large inputs, decodeStream invokes a callback per row instead of allocating a vector of the entire file.

import Control.Monad (void)
import Data.ByteString (ByteString)
import Data.Text (Text)
import Data.Vector (Vector)
import qualified Data.Vector as V
import CSV.Decode (decodeStream)
import CSV.Value (defaultCSV)
streamRows :: ByteString -> (Vector Text -> IO ()) -> IO (Either String ())
streamRows bs handleRow = decodeStream defaultCSV bs handleRow
printEachRow :: ByteString -> IO ()
printEachRow bs =
void $ streamRows bs $ \row ->
print (V.toList row)

Each callback receives a Vector Text of fields for one row. Combine with fromCSVRow inside the callback when you want typed values row by row.

wireform-csv encode + decode (Sale record) wireform-csv encode + decode (Sale record) lower is better · µs · ghc-9.8.4 on darwin-aarch64, criterion 1.6.5 0 500 1000 1500 2000 5.20 10.8 685 1432 10 rows 1000 rows encode decode wireform-csv encode + decode (Sale record) lower is better · µs · ghc-9.8.4 on darwin-aarch64, criterion 1.6.5 0 500 1000 1500 2000 5.20 10.8 685 1432 10 rows 1000 rows encode decode
Operationencodedecoderatio
10 rows5.20 µs10.8 µs2.08x
1000 rows685 µs1432 µs2.09x

Last run 2026-06-27 11:35:54 UTC. ghc-9.8.4 on darwin-aarch64, criterion 1.6.5.

The chart and table above are regenerated by wireform-stats from wireform-csv/bench-results/summary/csv-encode-decode.json — the same source the README chart is built from.

ModuleRole
CSV.ClassToCSV / FromCSV and Generic helpers
CSV.ValueCSVDocument, CSVConfig, defaultCSV, defaultTSV
CSV.Decodedecode, decodeStream, decodeRecords
CSV.Encodeencode, encodeRecords
CSV.DeriveTemplate Haskell deriver