diff --git a/box-csv.cabal b/box-csv.cabal
new file mode 100644
--- /dev/null
+++ b/box-csv.cabal
@@ -0,0 +1,68 @@
+cabal-version: 2.4
+name: box-csv
+version: 0.0.1
+synopsis: See readme.md
+description: See readme.md for description.
+category: project
+author: Tony Day
+maintainer: tonyday567@gmail.com
+copyright: Tony Day (c) AfterTimes
+license: BSD-3-Clause
+homepage: https://github.com/tonyday567/box-csv#readme
+bug-reports: https://github.com/tonyday567/box-csv/issues
+build-type: Simple
+source-repository head
+  type: git
+  location: https://github.com/tonyday567/box-csv
+
+library
+  hs-source-dirs:
+    src
+  default-extensions:
+    NoImplicitPrelude
+    NegativeLiterals
+    OverloadedStrings
+    UnicodeSyntax
+  ghc-options:
+    -Wall
+    -Wcompat
+    -Wincomplete-record-updates
+    -Wincomplete-uni-patterns
+    -Wredundant-constraints
+  build-depends:
+    base >=4.7 && <5,
+    attoparsec >= 0.13,
+    box >= 0.4 && < 0.6,
+    foldl >= 1.4,
+    generic-lens,
+    lens >= 4.19,
+    text >= 1.2,
+    time >= 1.9,
+    scientific >= 0.3,
+    numhask >= 0.6
+  exposed-modules:
+    Box.Csv
+  other-modules:
+  default-language: Haskell2010
+
+test-suite test
+  type: exitcode-stdio-1.0
+  main-is: test.hs
+  hs-source-dirs:
+      test
+  default-extensions:
+    NoImplicitPrelude
+    NegativeLiterals
+    OverloadedStrings
+    UnicodeSyntax
+  ghc-options:
+    -Wall
+    -Wcompat
+    -Wincomplete-record-updates
+    -Wincomplete-uni-patterns
+    -Wredundant-constraints
+  build-depends:
+    base >=4.7 && <5,
+    doctest >= 0.16,
+    numhask >= 0.6
+  default-language: Haskell2010
diff --git a/src/Box/Csv.hs b/src/Box/Csv.hs
new file mode 100644
--- /dev/null
+++ b/src/Box/Csv.hs
@@ -0,0 +1,218 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedLabels #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | A csv process based on attoparsec and the box library
+--
+module Box.Csv
+  ( CsvConfig (..),
+    defaultCsvConfig,
+    file,
+    Header (..),
+    rowEmitter,
+    rowCommitter,
+    runCsv,
+
+    -- * parsers
+    sep,
+    field_,
+    field,
+    skipField_,
+    skipField,
+    int,
+    int',
+    A.double,
+    double',
+    fields,
+    scis,
+    ints,
+    doubles,
+  )
+where
+
+import Box
+import Control.Lens
+import qualified Data.Attoparsec.Text as A
+import Data.Generics.Labels ()
+import Data.Scientific
+import qualified Data.Text as Text
+import NumHask.Prelude
+
+-- $setup
+-- >>> :set -XOverloadedStrings
+
+-- | csv file configuration
+data CsvConfig
+  = CsvConfig
+      { -- | file name stem
+        name :: Text,
+        -- | file suffix
+        suffix :: Text,
+        -- | directory
+        dir :: Text,
+        -- | field separator
+        fsep :: Char,
+        -- | nature of header row(s)
+        header :: Header
+      }
+  deriving (Show, Generic, Eq)
+
+-- | default csv file details
+--
+-- >>> defaultCsvConfig
+-- CsvConfig {name = "time_series_covid19_deaths_global_narrow", suffix = ".csv", dir = "./other", fsep = ',', header = HasHXL}
+--
+-- test data from https://data.humdata.org/dataset/novel-coronavirus-2019-ncov-cases
+defaultCsvConfig :: CsvConfig
+defaultCsvConfig =
+  CsvConfig
+    "time_series_covid19_deaths_global_narrow"
+    ".csv"
+    "./other"
+    ','
+    HasHXL
+
+-- | filepath for the config.
+--
+-- >>> file defaultCsvConfig
+-- "./other/time_series_covid19_deaths_global_narrow.csv"
+file :: CsvConfig -> FilePath
+file cfg =
+  cfg ^. #dir
+    <> "/"
+    <> cfg ^. #name
+    <> cfg ^. #suffix
+      & unpack
+
+-- | Type of header rows.  Note the modern propensity for multiple header rows.
+data Header = HasHeader | HasHXL | NoHeader deriving (Show, Eq)
+
+-- | A continuation emitter of parsed csv rows from a CsvConfig, returning the original text on failure
+-- >>> rowEmitter defaultCsvConfig fields `with` emit
+-- Just (Right ["Province/State","Country/Region","Lat","Long","Date","Value","ISO 3166-1 Alpha 3-Codes","Region Code","Sub-region Code","Intermediate Region Code\r"])
+rowEmitter :: CsvConfig -> (Char -> A.Parser a) -> Cont IO (Emitter IO (Either Text a))
+rowEmitter cfg p = parseE (p (view #fsep cfg)) <$> fileE (file cfg)
+
+-- | commits printed csv rows
+--
+-- >>> let testConfig = CsvConfig "test" ".csv" "./test" ',' NoHeader
+-- >>> let ctest = rowCommitter testConfig (fmap (Text.intercalate "," . fmap show))
+-- >>> ctest `with` (\c -> commit c [[1..10::Int]])
+-- True
+--
+-- >>> rowEmitter testConfig ints `with` emit
+-- Just (Right [1,2,3,4,5,6,7,8,9,10])
+--
+rowCommitter :: CsvConfig -> (a -> [Text]) -> Cont IO (Committer IO a)
+rowCommitter cfg f = contramap (Text.intercalate (Text.singleton $ view #fsep cfg) . f) <$> fileWriteC (file cfg)
+
+-- | Run a parser across all lines of a file.
+--
+-- >>> r1 <- runCsv defaultCsvConfig fields
+-- >>> length r1
+-- 42562
+--
+-- >>> length [x | (Left x) <- r1]
+-- 0
+--
+-- >>> take 2 $ drop 2 [x | (Right x) <- r1]
+-- [["","Afghanistan","33.0","65.0","2020-06-29","733","AFG","142","34","\r"],["","Afghanistan","33.0","65.0","2020-06-28","721","AFG","142","34","\r"]]
+runCsv :: CsvConfig -> (Char -> A.Parser a) -> IO [Either Text a]
+runCsv cfg p = with (rowEmitter cfg p) toListE
+
+-- * low-level generic csv parser helpers
+
+-- | Most parsing and building routines implicity assume a character acting as a separator of fields, and newlines separating rows.
+--
+-- >>> A.parse (sep ',') ",ok"
+-- Done "ok" ()
+sep :: Char -> A.Parser ()
+sep c = void (A.char c)
+
+-- * single field parsers
+
+-- | an unquoted field
+-- Does not consume the separator token
+--
+-- >>> A.parse (field_ ',') "field,ok"
+-- Done ",ok" "field"
+field_ :: Char -> A.Parser Text
+field_ c = A.takeWhile (/= c)
+
+-- | an unquoted field
+-- Consume the separator token
+--
+-- >>> A.parse (field ',') "field,ok"
+-- Done "ok" "field"
+field :: Char -> A.Parser Text
+field c = A.takeWhile (/= c) <* A.char c
+
+-- | skipping a field
+--
+-- >>> A.parse (skipField_ ',') "field,ok"
+-- Done ",ok" ()
+skipField_ :: Char -> A.Parser ()
+skipField_ c = A.skipWhile (/= c)
+
+-- | skipping a field
+--
+-- >>> A.parse (skipField ',') "field,ok"
+-- Done "ok" ()
+skipField :: Char -> A.Parser ()
+skipField c = A.skipWhile (/= c) <* A.char ','
+
+-- | int parser
+--
+-- >>> A.parse int "234,ok"
+-- Done ",ok" 234
+int :: A.Parser Int
+int = A.decimal
+
+-- | int parser, consumes separator
+--
+-- >>> A.parse (int' ',') "234,ok"
+-- Done "ok" 234
+int' :: Char -> A.Parser Int
+int' c = A.decimal <* A.char c
+
+-- | double parser, consumes separator
+--
+-- >>> A.parse (double' ',') "234.000,ok"
+-- Done "ok" 234.0
+double' :: Char -> A.Parser Double
+double' c = A.double <* A.char c
+
+-- * Block list parsers
+-- | Parser for a csv row of [Text].
+-- TODO: deal with potential for an extra '\r'
+--
+-- >>> A.parseOnly (fields ',') "field1,field2\r"
+-- Right ["field1","field2\r"]
+--
+fields :: Char -> A.Parser [Text]
+fields c =
+  field_ c `A.sepBy1` (sep c)
+
+-- | parser for a csv row of [Scientific]
+--
+-- >>> A.parseOnly (scis ',') "1,2.2,3.3"
+-- Right [1.0,2.2,3.3]
+scis :: Char -> A.Parser [Scientific]
+scis c = A.scientific `A.sepBy1` sep c
+
+-- | parser for a csv row of [Double]
+--
+-- >>> A.parseOnly (doubles ',') "1,2,3"
+-- Right [1.0,2.0,3.0]
+doubles :: Char -> A.Parser [Double]
+doubles c = A.double `A.sepBy1` sep c
+
+-- | parser for a csv row of [Int]
+--
+-- >>> A.parseOnly (ints ',') "1,2,3"
+-- Right [1,2,3]
+ints :: Char -> A.Parser [Int]
+ints c = A.signed A.decimal `A.sepBy1` sep c
diff --git a/test/test.hs b/test/test.hs
new file mode 100644
--- /dev/null
+++ b/test/test.hs
@@ -0,0 +1,19 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedLabels #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# OPTIONS_GHC -Wall #-}
+
+module Main where
+
+import NumHask.Prelude
+import Test.DocTest
+
+main :: IO ()
+main =
+  doctest
+    [ "src/Box/Csv.hs"
+    ]
