diff --git a/BTree.hs b/BTree.hs
deleted file mode 100644
--- a/BTree.hs
+++ /dev/null
@@ -1,31 +0,0 @@
-module BTree ( -- * Basic types
-               BLeaf(..)
-             , Size
-             , Order
-               -- * Building trees
-             , fromOrderedToFile
-             , fromOrderedToByteString
-             , fromUnorderedToFile
-               -- * Looking up in trees
-             , LookupTree
-             , open
-             , fromByteString
-             , lookup
-               -- * Merging trees
-             , mergeTrees
-             , mergeLeaves
-             , sizedProducerForTree
-               -- * Iterating over leaves
-             , walkLeaves
-             ) where
-
-import Prelude hiding (lookup)
-import BTree.Types
-import BTree.Merge
-import BTree.Builder
-import BTree.Lookup
-import BTree.Walk
-import BTree.BuildUnordered
-
--- | This package provides immutable B* trees targetting large data
--- sets requiring secondary storage.
diff --git a/BTree/BinaryFile.hs b/BTree/BinaryFile.hs
deleted file mode 100644
--- a/BTree/BinaryFile.hs
+++ /dev/null
@@ -1,87 +0,0 @@
-module BTree.BinaryFile
-    ( writeWithHeader
-    , readWithHeader
-    ) where
-
-import Control.Monad (when)
-import Control.Error
-import Control.Monad.Trans.Class
-import Control.Applicative
-import Data.Word
-import System.IO
-
-import qualified Data.ByteString.Lazy as LBS
-import qualified Data.Binary as B
-import qualified Data.Binary.Get as B
-import qualified Data.Binary.Put as B
-import Pipes
-
--- | This module provides helpers for emitting and reading binary files with
--- a trailing "header".
-
--- | An internal data structure placed at the very end of the file which
--- describes the header and provides a magic number for sanity checking.
-data Epilogue = Epilogue { magic :: Word64
-                         , headerLen :: Word64
-                         }
-              deriving (Show)
-
-epiLength :: Integer
-epiLength = 16
-
-magicNumber :: Word64
-magicNumber = 0xdeadbeef
-
-instance B.Binary Epilogue where
-    get = Epilogue <$> B.getWord64le <*> B.getWord64le
-    put (Epilogue m l) = B.putWord64le m >> B.putWord64le l
-
--- | Write the produced bytestrings to the file followed by the
--- returned header
-writeWithHeader :: (MonadIO m, B.Binary hdr)
-                => FilePath
-                -> Producer LBS.ByteString m (hdr, r)
-                -> m r
-writeWithHeader fname prod = do
-    h <- liftIO $ openFile fname WriteMode
-    (hdr, r) <- runEffect $ for prod (liftIO . LBS.hPut h)
-    let encoded = B.encode hdr
-    liftIO $ LBS.hPut h encoded
-    let epi = Epilogue { magic = magicNumber
-                       , headerLen = fromIntegral $ LBS.length encoded }
-    liftIO $ LBS.hPut h (B.encode epi)
-    liftIO $ hClose h
-    return r
-
-annotate :: Monad m => String -> EitherT String m a -> EitherT String m a
-annotate ann = fmapLT ((ann++": ")++)
-
-runGetT :: Monad m => B.Get a -> LBS.ByteString -> EitherT String m a
-runGetT _get bs = do
-    case B.runGetOrFail _get bs of
-      Left (_, _, e)  -> left e
-      Right (_, _, a) -> right a
-
--- | Read and verify the header from the file, then pass it along with the
--- file's handle to an action. The file handle sits at the beginning of the
--- written content when passed to the action.
-readWithHeader :: (MonadIO m, B.Binary hdr)
-               => FilePath
-               -> (hdr -> Handle -> m a)
-               -> EitherT String m a
-readWithHeader fname action = do
-    h <- liftIO $ openFile fname ReadMode
-    -- read epilogue
-    liftIO $ hSeek h SeekFromEnd (-epiLength)
-    epiBytes <- liftIO (LBS.hGet h $ fromIntegral epiLength)
-    epi <- annotate "Error reading epilogue" (runGetT B.get epiBytes)
-    when (magic epi /= magicNumber) $
-        left "BinaryFile.readWithHeader: Bad magic number"
-    -- read header
-    let offset = fromIntegral epiLength + fromIntegral (headerLen epi)
-    liftIO $ hSeek h SeekFromEnd (negate offset)
-    hdrBytes <- liftIO (LBS.hGet h $ fromIntegral $ headerLen epi)
-    hdr <- annotate "Error reading header" (runGetT B.get hdrBytes)
-    -- pass control to action
-    liftIO $ hSeek h AbsoluteSeek 0
-    lift $ action hdr h
diff --git a/BTree/BinaryList.hs b/BTree/BinaryList.hs
deleted file mode 100644
--- a/BTree/BinaryList.hs
+++ /dev/null
@@ -1,83 +0,0 @@
-{-# LANGUAGE BangPatterns #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-
-module BTree.BinaryList
-    ( BinaryList
-      -- * Construction
-    , toBinaryList
-      -- * Fetching contents
-    , stream
-      -- * Other queries
-    , length
-    , filePath
-    ) where
-
-import Prelude hiding (length)
-import Control.Applicative
-import Control.Monad.Trans.Class
-import Control.Error
-import Data.Word
-import System.IO
-
-import qualified Data.ByteString.Lazy as LBS
-import qualified Data.Binary as B
-import qualified Data.Binary.Get as B
-import qualified Data.Binary.Put as B
-import Pipes
-
-import BTree.BinaryFile
-
--- | A file containing a finite list of binary encoded items
-newtype BinaryList a = BinList FilePath
-                     deriving (Show)
-
--- | Get the path to the @BinaryList@ file
-filePath :: BinaryList a -> FilePath
-filePath (BinList f) = f
-
-data Header = Header { hdrLength :: Word64 }
-            deriving (Show)
-
-instance B.Binary Header where
-    get = Header <$> B.getWord64le
-    put (Header l) = B.putWord64le l
-
--- | Encode the items of the given producer
-toBinaryList :: forall m a r. (MonadIO m, B.Binary a)
-             => FilePath -> Producer a m r -> m (BinaryList a, r)
-toBinaryList fname producer = do
-    writeWithHeader fname (go 0 producer)
-  where
-    go :: Int -> Producer a m r
-       -> Producer LBS.ByteString m (Header, (BinaryList a, r))
-    go !n prod = do
-        result <- lift $ next prod
-        case result of
-          Left r ->
-            let hdr = Header (fromIntegral n)
-            in return (hdr, (BinList fname, r))
-          Right (a, prod') -> do
-            yield (B.encode a)
-            go (n+1) prod'
-
-withHeader :: MonadIO m
-           => BinaryList a -> (Header -> Handle -> m b) -> EitherT String m b
-withHeader (BinList fname) action = readWithHeader fname action
-
-length :: MonadIO m => BinaryList a -> EitherT String m Word64
-length bl = withHeader bl $ \hdr _ -> return $ hdrLength hdr
-
--- | Stream the items out of a @BinaryList@
-stream :: forall m a. (B.Binary a, MonadIO m)
-       => BinaryList a -> EitherT String m (Producer a m (Either String ()))
-stream bl = withHeader bl readContents
-  where
-    readContents :: Header -> Handle -> m (Producer a m (Either String ()))
-    readContents hdr h = return $ liftIO (LBS.hGetContents h) >>= go (hdrLength hdr)
-
-    go :: Word64 -> LBS.ByteString -> Producer a m (Either String ())
-    go 0  _  = return $ Right ()
-    go !n bs =
-      case B.decodeOrFail bs of
-        Left (_, _, e)    -> return $ Left e
-        Right (bs', _, a) -> yield a >> go (n-1) bs'
diff --git a/BTree/BuildUnordered.hs b/BTree/BuildUnordered.hs
deleted file mode 100644
--- a/BTree/BuildUnordered.hs
+++ /dev/null
@@ -1,109 +0,0 @@
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE BangPatterns #-}
-{-# LANGUAGE FlexibleContexts #-}
-
-module BTree.BuildUnordered
-    ( fromUnorderedToFile ) where
-
-import Control.Monad.Trans.State
-import Control.Error
-import Data.Traversable (forM)
-
-import qualified Data.Binary as B
-import qualified Data.Set as S
-import System.IO
-import System.Directory (removeFile)
-
-import Pipes
-import Pipes.Interleave
-import qualified BTree.BinaryList as BL
-import BTree.Types
-import BTree.Builder
-
--- | Maximum number of leaf lists to attempt to merge at once.
--- This is bounded by the maximum file handle count.
-maxChunkMerge :: Int
-maxChunkMerge = 100
-
-tempFilePath :: FilePath -> String -> IO FilePath
-tempFilePath dir template = do
-    (fname, h) <- liftIO $ openTempFile dir template
-    hClose h
-    return fname
-
--- | Build a B-tree into the given file.
---
--- This does not assume that the leaves are produced in order. Instead,
--- the sorting is handled internally through a simple merge sort. Chunks of
--- leaves are collected, sorted in memory, and then written to intermediate
--- trees. At the end these trees are then merged.
-fromUnorderedToFile :: forall m e k r.
-                       (MonadIO m, B.Binary (BLeaf k e), B.Binary k, B.Binary e, Ord k)
-                    => FilePath                   -- ^ Path to scratch directory
-                    -> Int                        -- ^ Maximum chunk size
-                    -> Order                      -- ^ Order of tree
-                    -> FilePath                   -- ^ Output file
-                    -> Producer (BLeaf k e) m r   -- ^ 'Producer' of elements
-                    -> EitherT String m ()
-fromUnorderedToFile scratch maxChunk order dest producer = do
-    bList <- lift (execStateT (fillLists producer) []) >>= goMerge
-    size <- BL.length bList
-    stream <- BL.stream bList
-    lift $ fromOrderedToFile order size dest stream
-    liftIO $ removeFile $ BL.filePath bList
-  where
-    fillLists :: Producer (BLeaf k e) m r -> StateT [BL.BinaryList (BLeaf k e)] m r
-    fillLists prod = do
-      fname <- liftIO $ tempFilePath scratch "chunk.list"
-      (leaves, rest) <- lift $ takeChunk maxChunk prod
-      (bList, ()) <- lift $ BL.toBinaryList fname $ each $ S.toAscList leaves
-      modify (bList:)
-      case rest of
-        Left r         -> return r
-        Right nextProd -> fillLists nextProd
-
-    goMerge :: [BL.BinaryList (BLeaf k e)] -> EitherT String m (BL.BinaryList (BLeaf k e))
-    goMerge [l] = return l
-    goMerge ls = do
-      ls'' <- forM (splitChunks maxChunkMerge ls) $ \ls'->do
-        fname <- liftIO $ tempFilePath scratch "merged.list"
-        list <- mergeLists fname ls'
-        liftIO $ mapM_ (removeFile . BL.filePath) ls'
-        return list
-      goMerge ls''
-
--- | Split the list into chunks of bounded size and run each through a function
-splitChunks :: Int -> [a] -> [[a]]
-splitChunks chunkSize = go
-  where
-    go [] = []
-    go xs = let (prefix,suffix) = splitAt chunkSize xs
-            in prefix : go suffix
-
-throwLeft :: Monad m => m (Either String r) -> m r
-throwLeft action = action >>= either error return
-
-mergeLists :: (Ord a, B.Binary a, MonadIO m)
-           => FilePath -> [BL.BinaryList a] -> EitherT String m (BL.BinaryList a)
-mergeLists dest lists = do
-    streams <- mapM BL.stream lists
-    let prod = interleave compare (map throwLeft streams)
-    (bList, ()) <- lift $ BL.toBinaryList dest prod
-    return bList
-
--- | Take the first 'n' elements and collect them in a 'Set'. Return
--- a 'Producer' which will emit the remaining elements (or the return
--- value).
-takeChunk :: forall m a r. (Monad m, Ord a)
-          => Int
-          -> Producer a m r
-          -> m (S.Set a, Either r (Producer a m r))
-takeChunk n = go n S.empty
-  where
-    go :: Int -> S.Set a -> Producer a m r -> m (S.Set a, Either r (Producer a m r))
-    go 0 s prod = return (s, Right prod)
-    go i s prod = do
-      result <- next prod
-      case result of
-        Left r -> return (s, Left r)
-        Right (a, prod') -> go (i-1) (S.insert a s) prod'
diff --git a/BTree/Builder.hs b/BTree/Builder.hs
deleted file mode 100644
--- a/BTree/Builder.hs
+++ /dev/null
@@ -1,231 +0,0 @@
-{-# LANGUAGE TemplateHaskell #-}
-
-module BTree.Builder
-    ( buildNodes, putBS
-    , fromOrderedToFile
-    , fromOrderedToByteString
-    ) where
-
-import Control.Monad.Trans.State.Strict
-import Control.Monad.IO.Class
-import Control.Monad
-
-import Data.Foldable as F
-import qualified Data.Sequence as Seq
-import           Data.Sequence (Seq)
-
-import Data.Ratio
-import Control.Lens
-import System.IO
-
-import qualified Data.Binary as B
-import           Data.Binary (Binary)
-import qualified Data.ByteString.Lazy as LBS
-
-import Pipes
-import Pipes.Core
-import qualified Pipes.Internal as PI
-
-import BTree.Types
-
--- | A Producer which accepts offsets for the yielded objects in return
-type DiskProducer a = Proxy X () (OnDisk a) a
-
-putBS :: (Binary a, Monad m) => a -> Proxy (OnDisk a) a () LBS.ByteString m r
-putBS a0 = evalStateT (go a0) 0
-  where
-    go a = do
-        s <- get
-        let bs = B.encode a
-        put $! s + fromIntegral (LBS.length bs)
-        lift $ yield bs
-        a' <- lift $ request (OnDisk s)
-        go a'
-
-data DepthState k e = DepthS { -- | nodes to be included in the active node
-                               _dNodes       :: !(Seq (k, OnDisk (BTree k OnDisk e)))
-                               -- | the length of @dNodes@
-                             , _dNodeCount   :: !Int
-                               -- | the desired number of elements to fill the active node
-                             , _dMinFill     :: [Int]
-                             }
-makeLenses ''DepthState
-
-next' :: (Monad m) => Proxy X () a' a m r -> m (Either r (a, a' -> Proxy X () a' a m r))
-next' = go
-  where
-    go p = case p of
-      PI.Request _ fu -> go (fu ())
-      PI.Respond a fu -> return (Right (a, fu))
-      PI.M         m  -> m >>= go
-      PI.Pure    r    -> return (Left r)
-
--- | Compute the optimal node sizes for each stratum of a tree of
--- given size and order
-optimalFill :: Order -> Size -> [[Int]]
-optimalFill order size = go (fromIntegral size)
-  where
-    go :: Int -> [[Int]]
-    go 0 = error "BTree.Builder.optimalFill: zero size"
-    go n =
-      let nNodes = ceiling (n % order')
-          order' = fromIntegral order :: Int
-          nodes = let (nPerNode, leftover) = n `divMod` nNodes
-                  in zipWith (+) (replicate nNodes nPerNode)
-                                 (replicate leftover 1 ++ repeat 0)
-          rest = case nNodes of
-                   1  -> []
-                   _  -> go nNodes
-      in nodes : rest
-
--- | Given a producer of a known number of leaves, produces an optimal B-tree.
--- Technically the size is only an upper bound: the producer may
--- terminate before providing the given number of leaves although the resulting
--- tree will break the minimal fill invariant.
-buildNodes :: Monad m
-           => Order -> Size
-           -> DiskProducer (BLeaf k e) m r
-           -> DiskProducer (BTree k OnDisk e) m (BTreeHeader k e)
-buildNodes order size =
-    flip evalStateT initialState . loop size
-  where
-    initialState = map (DepthS Seq.empty 0) $ optimalFill order size
-    -- depth=0 denotes the bottom (leaves) of the tree.
-    loop :: Monad m
-         => Size -> DiskProducer (BLeaf k e) m r
-         -> StateT [DepthState k e] (DiskProducer (BTree k OnDisk e) m)
-                   (BTreeHeader k e)
-    loop n producer = do
-        _next <- lift $ lift $ next' producer
-        case _next of
-            Left _  -> do
-                flushAll (size - n)
-            Right _ | n == 0 -> do
-                flushAll (size - n)
-            Right (leaf@(BLeaf k _), producer') -> do
-                -- TODO: Is there a way to check this coercion with the type system?
-                OnDisk offset <- processNode k $ Leaf leaf
-                loop (n-1) $ producer' (OnDisk offset)
-
-    isFilled :: Monad m
-             => StateT [DepthState k e] m Bool
-    isFilled = zoom (singular _head) $ do
-        nodeCount <- use dNodeCount
-        minFill:_ <- use dMinFill
-        return $ nodeCount >= minFill
-
-    emitNode :: Monad m
-             => StateT [DepthState k e] (DiskProducer (BTree k OnDisk e) m)
-                       (OnDisk (BTree k OnDisk e))
-    emitNode = do
-        (k0,node0):nodes <- zoom (singular _head) $ do
-            nodes <- uses dNodes F.toList
-            dNodes .= Seq.empty
-            dNodeCount .= 0
-            dMinFill %= tail
-            return nodes
-
-        --when (null nodes)
-        --  $ error "BTree.Builder.buildNodes: Internal invariant broken: unexpected empty node"
-        let newNode = Node node0 nodes
-        s <- get
-        case s of
-            [_] -> lift $ respond newNode
-            _   -> zoom (singular _tail) $ processNode k0 newNode
-
-    processNode :: Monad m
-                => k -> BTree k OnDisk e
-                -> StateT [DepthState k e]
-                          (DiskProducer (BTree k OnDisk e) m)
-                          (OnDisk (BTree k OnDisk e))
-    processNode startKey tree = do
-        filled <- isFilled
-        when filled $ void $ emitNode
-        offset <- lift $ respond tree
-        zoom _head $ do
-            dNodes %= (Seq.|> (startKey, offset))
-            dNodeCount += 1
-        return offset
-
-    flushAll :: Monad m
-             => Size
-             -> StateT [DepthState k e]
-                       (DiskProducer (BTree k OnDisk e) m)
-                       (BTreeHeader k e)
-    flushAll realSize = do
-        s <- get
-        case s of
-            []   -> error "BTree.Builder.flushAll: We should never get here"
-            [_]  -> do -- We are at the top node, this shouldn't be flushed yet
-                       root <- emitNode
-                       return $ BTreeHeader magic 1 order realSize root
-            d:_  -> do when (not $ Seq.null $ d^.dNodes) $ void $ emitNode
-                       zoom (singular _tail) $ flushAll realSize
-
--- | Produce a bytestring representing the nodes and leaves of the
--- B-tree and return a suitable header
-buildTree :: (Monad m, Binary e, Binary k)
-          => Order -> Size
-          -> Producer (BLeaf k e) m r
-          -> Producer LBS.ByteString m (BTreeHeader k e)
-buildTree order size producer =
-    dropUpstream $ buildNodes order size (dropUpstream producer) >>~ putBS
-
-dropUpstream :: Monad m => Proxy X () () b m r -> Proxy X () b' b m r
-dropUpstream = go
-  where
-    go producer = do
-        n <- lift $ next producer
-        case n of
-            Left r               -> return r
-            Right (a, producer') -> respond a >> go producer'
-
--- | Build a B-tree into the given file.
---
--- As the name suggests, this requires that the @Producer@ emits
--- leaves in ascending key order.
-fromOrderedToFile :: (MonadIO m, Binary e, Binary k)
-                  => Order                     -- ^ Order of tree
-                  -> Size                      -- ^ Maximum tree size
-                  -> FilePath                  -- ^ Output file
-                  -> Producer (BLeaf k e) m r  -- ^ 'Producer' of elements
-                  -> m ()
-fromOrderedToFile order size fname producer = do
-    h <- liftIO $ openFile fname WriteMode
-    liftIO $ LBS.hPut h $ B.encode invalidHeader
-    hdr <- runEffect $ for (buildTree order size producer) $ liftIO . LBS.hPut h
-    liftIO $ hSeek h AbsoluteSeek 0
-    liftIO $ LBS.hPut h $ B.encode hdr
-    liftIO $ hClose h
-    return ()
-  where
-    invalidHeader = BTreeHeader 0 0 0 0 (OnDisk 0)
-
--- | Build a B-tree into @ByteString@
---
--- As the name suggests, this requires that the @Producer@ emits
--- leaves in ascending key order.
---
--- This is primarily used for testing. In particular, note that
--- this is a bad idea for large trees as the entire contents of the
--- tree will need to be kept in memory until all leaves have been
--- added so that the header can be prepended.
-fromOrderedToByteString :: (Monad m, Binary e, Binary k)
-                        => Order                     -- ^ Order of tree
-                        -> Size                      -- ^ Maximum tree size
-                        -> Producer (BLeaf k e) m r  -- ^ 'Producer' of elements
-                        -> m LBS.ByteString
-fromOrderedToByteString order size producer = do
-    (bs, hdr) <- foldR LBS.append LBS.empty id $ buildTree order size producer
-    return $ B.encode hdr `LBS.append` bs
-
--- | Like @Pipes.Prelude.fold@ but provides returns producer result
--- in addition to accumulator
-foldR :: Monad m => (x -> a -> x) -> x -> (x -> b) -> Producer a m r -> m (b, r)
-foldR step begin done p0 = loop p0 begin
-  where
-    loop p x = case p of
-        PI.Request _  fu -> loop (fu ()) x
-        PI.Respond a  fu -> loop (fu ()) $! step x a
-        PI.M          m  -> m >>= \p' -> loop p' x
-        PI.Pure    r     -> return (done x, r)
diff --git a/BTree/Lookup.hs b/BTree/Lookup.hs
deleted file mode 100644
--- a/BTree/Lookup.hs
+++ /dev/null
@@ -1,47 +0,0 @@
-module BTree.Lookup ( LookupTree
-                    , open
-                    , fromByteString
-                    , lookup
-                    ) where
-
-import Prelude hiding (lookup)
-import Control.Error
-import Control.Lens hiding (children)
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Lazy as LBS
-import Data.Binary
-import System.IO.MMap
-import BTree.Types
-
-fetch :: (Binary a) => LookupTree k e -> OnDisk a -> a
-fetch lt (OnDisk offset) =
-    decode $ LBS.fromStrict $ BS.drop (fromIntegral offset) (lt^.ltData)
-
--- | Read a B-tree from a 'ByteString' produced by 'BTree.Builder'
-fromByteString :: LBS.ByteString -> Either String (LookupTree k e)
-fromByteString bs = do
-    (rest, _, hdr) <- fmapL (\(_,_,e)->e) $ decodeOrFail bs
-    validateHeader hdr
-    return $ LookupTree (LBS.toStrict rest) hdr
-
--- | Open a B-tree file.
-open :: FilePath -> IO (Either String (LookupTree k e))
-open fname = runEitherT $ do
-    d <- fmapLT show $ tryIO $ mmapFileByteString fname Nothing
-    EitherT $ return $ fromByteString (LBS.fromStrict d)
-   
--- | Lookup a key in a B-tree.
-lookup :: (Binary k, Binary e, Ord k)
-       => LookupTree k e -> k -> Maybe e
-lookup lt k = go $ fetch lt (lt ^. ltHeader . btRoot)
-  where
-    go (Leaf (BLeaf k' e))
-      | k' == k     = Just e
-      | otherwise   = Nothing
-    go (Node c0 []) = go $ fetch lt c0 -- is this case necessary?
-    go (Node c0 children@((k0,_):_))
-      | k < k0      = go $ fetch lt c0
-      | otherwise   =
-          case takeWhile (\(k',_)->k' <= k) children of
-            []  -> Nothing
-            xs  -> go $ fetch lt $ snd $ last xs
diff --git a/BTree/Merge.hs b/BTree/Merge.hs
deleted file mode 100644
--- a/BTree/Merge.hs
+++ /dev/null
@@ -1,61 +0,0 @@
-{-# LANGUAGE TemplateHaskell, BangPatterns, GeneralizedNewtypeDeriving #-}
-
-module BTree.Merge ( mergeTrees
-                   , mergeLeaves
-                   , sizedProducerForTree
-                   ) where
-
-import Prelude hiding (sum)
-import Control.Applicative
-import Data.Foldable
-import Data.Function (on)
-import Control.Monad.State hiding (forM_)
-import Data.Binary
-import Control.Lens
-import Pipes
-import Pipes.Interleave
-
-import BTree.Types
-import BTree.Builder
-import BTree.Walk
-
--- | Merge trees' leaves taking ordered leaves from a set of producers.
---
--- Each producer must be annotated with the number of leaves it is
--- expected to produce. The size of the resulting tree will be at most
--- the sum of these sizes.
-mergeLeaves :: (MonadIO m, Functor m, Binary k, Binary e, Ord k)
-            => (e -> e -> m e)               -- ^ merge operation on elements
-            -> Order                         -- ^ order of merged tree
-            -> FilePath                      -- ^ name of output file
-            -> [(Size, Producer (BLeaf k e) m ())]   -- ^ producers of leaves to merge
-            -> m ()
-mergeLeaves append destOrder destFile producers = do
-    let size = sum $ map fst producers
-    fromOrderedToFile destOrder size destFile $
-      mergeM (compare `on` key) doAppend (map snd producers)
-  where
-    doAppend (BLeaf k e) (BLeaf _ e') = BLeaf k <$> append e e'
-    key (BLeaf k _) = k
-
--- | Merge several 'LookupTrees'
---
--- This is a convenience function for merging several trees already on
--- disk. For a more flexible interface, see 'mergeLeaves'.
-mergeTrees :: (MonadIO m, Functor m, Binary k, Binary e, Ord k)
-           => (e -> e -> m e)        -- ^ merge operation on elements
-           -> Order                  -- ^ order of merged tree
-           -> FilePath               -- ^ name of output file
-           -> [LookupTree k e]       -- ^ trees to merge
-           -> m ()
-mergeTrees append destOrder destFile trees = do
-    mergeLeaves append destOrder destFile
-    $ map sizedProducerForTree trees
-
--- | Get a sized 'Producer' suitable for 'mergeLeaves' from a 'LookupTree'
-sizedProducerForTree :: (Monad m, Binary k, Binary e)
-                     => LookupTree k e   -- ^ a tree
-                     -> (Size, Producer (BLeaf k e) m ())
-                                         -- ^ a sized 'Producer' suitable for passing
-                                         -- to 'mergeLeaves'
-sizedProducerForTree lt = (lt ^. ltHeader . btSize, void $ walkLeaves lt)
diff --git a/BTree/Types.hs b/BTree/Types.hs
deleted file mode 100644
--- a/BTree/Types.hs
+++ /dev/null
@@ -1,97 +0,0 @@
-{-# LANGUAGE DeriveGeneric, FlexibleContexts, TemplateHaskell, UndecidableInstances, StandaloneDeriving #-}
-
-module BTree.Types where
-
-import Data.Binary
-import GHC.Generics
-import Control.Monad (when)
-import Control.Applicative
-import Control.Lens
-import Data.Int
-import qualified Data.ByteString as BS
-
--- | An offset within the stream
-type Offset = Int64
-
--- | The number of entries in a B-tree
-type Size = Word64
-
--- | The maximum number of children of a B-tree inner node
-type Order = Word64
-
--- | 'OnDisk a' is a reference to an object of type 'a' on disk.
--- The offset does not include the header; e.g. the first object after
--- the header is located at offset 0.
-newtype OnDisk a = OnDisk Offset
-                 deriving (Show, Eq, Ord)
-
-instance Binary (OnDisk a) where
-    get = OnDisk <$> get
-    put (OnDisk off) = put off
-
--- | A tree leaf (e.g. key/value pair)
-data BLeaf k e = BLeaf !k !e
-               deriving (Generic)
-
-deriving instance (Show k, Show e) => Show (BLeaf k e)
-
--- | This only compares on the keys
-instance (Eq k) => Eq (BLeaf k e) where
-    BLeaf a _ == BLeaf b _ = a == b
-
--- | This only compares on the keys
-instance Ord k => Ord (BLeaf k e) where
-    compare (BLeaf a _) (BLeaf b _) = compare a b
-
-instance (Binary k, Binary e) => Binary (BLeaf k e) where
-    get = BLeaf <$> get <*> get
-    put (BLeaf k e) = put k >> put e
-
--- | 'BTree k f e' is a B* tree of key type 'k' with elements of type 'e'.
--- Subtree references are contained within a type 'f'
---
--- The Node constructor contains a left child, and a list of key/child pairs
--- where each child's keys are greater than or equal to the given key.
-data BTree k f e = Node (f (BTree k f e)) [(k, f (BTree k f e))]
-                 | Leaf !(BLeaf k e)
-                 deriving (Generic)
-
-deriving instance (Show e, Show k, Show (f (BTree k f e))) => Show (BTree k f e)
-deriving instance (Eq e, Eq k, Eq (f (BTree k f e))) => Eq (BTree k f e)
-
-instance (Binary k, Binary (f (BTree k f e)), Binary e)
-  => Binary (BTree k f e) where
-    get = do typ <- getWord8
-             case typ of
-               0 -> Node <$> get <*> get
-               1 -> bleaf <$> get <*> get
-               _ -> fail "BTree.Types/get: Unknown node type"
-      where bleaf k v = Leaf (BLeaf k v)
-    put (Node e0 es)         = putWord8 0 >> put e0 >> put es
-    put (Leaf (BLeaf k0 e))  = putWord8 1 >> put k0 >> put e
-
-magic :: Word64
-magic = 0xdeadbeefbbbbcccc
-
--- | B-tree file header
-data BTreeHeader k e = BTreeHeader { _btMagic   :: !Word64
-                                   , _btVersion :: !Word64
-                                   , _btOrder   :: !Order
-                                   , _btSize    :: !Size
-                                   , _btRoot    :: !(OnDisk (BTree k OnDisk e))
-                                   }
-                 deriving (Show, Eq, Generic)
-makeLenses ''BTreeHeader
-
-instance Binary (BTreeHeader k e)
-
-validateHeader :: BTreeHeader k e -> Either String ()
-validateHeader hdr = do
-    when (hdr^.btMagic /= magic) $ Left "Invalid magic number"
-    when (hdr^.btVersion > 1) $ Left "Invalid version"
-
--- | A read-only B-tree for lookups
-data LookupTree k e = LookupTree { _ltData    :: !BS.ByteString
-                                 , _ltHeader  :: !(BTreeHeader k e)
-                                 }
-makeLenses ''LookupTree
diff --git a/BTree/Walk.hs b/BTree/Walk.hs
deleted file mode 100644
--- a/BTree/Walk.hs
+++ /dev/null
@@ -1,49 +0,0 @@
-module BTree.Walk ( walkLeaves
-                  , walkNodes
-                  , walkNodesWithOffset
-                  ) where
-
-import BTree.Types
-import qualified Data.ByteString.Lazy as LBS
-import Pipes
-import qualified Pipes.Prelude as PP
-import Data.Binary
-import Data.Binary.Get (runGetOrFail)
-import Control.Lens
-
--- If we only look at leaves keys will increase monotonically as we
--- progress through the file.
-
-filterLeaves :: Monad m => Pipe (BTree k OnDisk v) (BLeaf k v) m r
-filterLeaves = do
-    a <- await
-    case a of
-      Leaf leaf  -> yield leaf
-      _          -> return ()
-    filterLeaves
-
--- | Iterate over the leaves of the given tree in ascending key order.
-walkLeaves :: (Binary k, Binary v, Monad m)
-           => LookupTree k v
-           -> Producer (BLeaf k v) m (LBS.ByteString, Maybe String)
-walkLeaves b = walkNodes b >-> filterLeaves
-
--- | Iterate over the nodes and leaves of the given tree. These aren't
--- necessarily sorted.
-walkNodes :: (Binary k, Binary v, Monad m)
-          => LookupTree k v
-          -> Producer (BTree k OnDisk v) m (LBS.ByteString, Maybe String)
-walkNodes b = walkNodesWithOffset b >-> PP.map snd
-
-walkNodesWithOffset :: (Binary k, Binary v, Monad m)
-                    => LookupTree k v
-                    -> Producer (Offset, BTree k OnDisk v) m (LBS.ByteString, Maybe String)
-walkNodesWithOffset = go 0 . view (ltData . to LBS.fromStrict)
-  where go offset bs =
-            case runGetOrFail get bs of
-              Left (rest,_,err)  -> return (rest, Just err)
-              Right (rest,o,a)   -> do
-                yield (offset, a)
-                if LBS.null rest
-                  then return (rest, Nothing)
-                  else go (offset+o) rest
diff --git a/Benchmark.hs b/Benchmark.hs
deleted file mode 100644
--- a/Benchmark.hs
+++ /dev/null
@@ -1,28 +0,0 @@
-import BTree as BT
-import Criterion.Main
-import Pipes
-
-main = do
-    buildTree "hello.btree" 100 100000
-    Right lt <- BT.open "hello.btree"
-             :: IO (Either String (LookupTree Int Int))
-
-    defaultMain $ benchmarks lt
-
-benchmarks lt =
-    [ bench "build tree (order 10, 100000 elements)"
-      $ nfIO $ buildTree "test.btree" 10 100000
-    , bench "build tree (order 100, 100000 elements)"
-      $ nfIO $ buildTree "test.btree" 100 100000
-    , bench "lookup (5000 lookups)"
-      $ nf (\lt->map (lookupBench lt) [0..5000]) lt
-    ]
-
-buildTree :: FilePath -> Order -> Int -> IO ()
-buildTree fname order n = 
-    BT.fromOrderedToFile 4 (fromIntegral n) fname (each things)
-  where things :: [BLeaf Int Int]
-        things = [BLeaf (2*i) i | i <- [0..n-1]]
-        
-lookupBench :: LookupTree Int Int -> Int -> Maybe Int
-lookupBench lt = BT.lookup lt
diff --git a/b-tree.cabal b/b-tree.cabal
--- a/b-tree.cabal
+++ b/b-tree.cabal
@@ -1,5 +1,5 @@
 name:                b-tree
-version:             0.1.1
+version:             0.1.2
 synopsis:            Immutable disk-based B* trees
 description:         Immutable disk-based B* trees
 homepage:            http://github.com/bgamari/b-tree
@@ -7,10 +7,9 @@
 license-file:        LICENSE
 author:              Ben Gamari
 maintainer:          bgamari.foss@gmail.com
--- copyright:           
+copyright:           (c) 2014 Ben Gamari
 category:            Data
 build-type:          Simple
--- extra-source-files:  
 cabal-version:       >=1.10
 
 source-repository head
@@ -36,18 +35,19 @@
                        BangPatterns,
                        GeneralizedNewtypeDeriving
 
-  build-depends:       base >=4.6 && <4.8,
+  hs-source-dirs:      src
+  build-depends:       base >=4.6 && <4.9,
                        mtl >=2.0 && <3.0,
                        pipes >=4.1 && <4.2,
                        pipes-interleave >= 0.1 && <1.0,
                        bytestring >=0.10 && <0.11,
                        binary >=0.7 && <0.8,
                        transformers >=0.3 && <0.5,
-                       lens >=3.10 && <4.8,
+                       lens >=3.10 && <4.13,
                        containers >=0.5 && <0.6,
-                       vector >=0.10 && <0.11,
-                       errors >=1.4 && <1.5,
-                       filepath >=1.3 && <1.4,
+                       vector >=0.10 && <0.12,
+                       errors >=2.0 && <2.1,
+                       filepath >=1.3 && <1.5,
                        directory >=1.2 && <1.3,
                        mmap >=0.5 && <0.6
   default-language:    Haskell2010
@@ -57,7 +57,8 @@
   type:                exitcode-stdio-1.0
   main-is:             QuickCheck.hs
   hs-source-dirs:      tests
-  build-depends:       base >=4.6 && <4.8,
+  default-language:    Haskell2010
+  build-depends:       base >=4.6 && <4.9,
                        containers,
                        pipes,
                        binary,
@@ -67,18 +68,11 @@
 benchmark btree-benchmark
   type:                exitcode-stdio-1.0
   main-is:             Benchmark.hs
-  build-depends:       base >=4.6 && <4.8,
-                       mtl >=2.0 && <3.0,
-                       criterion >=0.8 && <0.10,
-                       pipes >=4.1 && <4.2,
-                       pipes-interleave >= 0.1 && <1.0,
-                       bytestring >=0.10 && <0.11,
-                       binary >=0.7 && <0.8,
-                       transformers >=0.3 && <0.5,
-                       lens >=3.10 && <4.3,
-                       containers >=0.5 && <0.6,
-                       vector >=0.10 && <0.11,
-                       errors >=1.4 && <1.5,
-                       mmap >=0.5 && <0.6
+  hs-source-dirs:      benchmarks
+  default-language:    Haskell2010
+  build-depends:       base >=4.6 && <4.9,
+                       b-tree,
+                       pipes,
+                       criterion >=0.8 && <2.0
   default-language:    Haskell2010
 
diff --git a/benchmarks/Benchmark.hs b/benchmarks/Benchmark.hs
new file mode 100644
--- /dev/null
+++ b/benchmarks/Benchmark.hs
@@ -0,0 +1,28 @@
+import BTree as BT
+import Criterion.Main
+import Pipes
+
+main = do
+    buildTree "hello.btree" 100 100000
+    Right lt <- BT.open "hello.btree"
+             :: IO (Either String (LookupTree Int Int))
+
+    defaultMain $ benchmarks lt
+
+benchmarks lt =
+    [ bench "build tree (order 10, 100000 elements)"
+      $ nfIO $ buildTree "test.btree" 10 100000
+    , bench "build tree (order 100, 100000 elements)"
+      $ nfIO $ buildTree "test.btree" 100 100000
+    , bench "lookup (5000 lookups)"
+      $ nf (\lt->map (lookupBench lt) [0..5000]) lt
+    ]
+
+buildTree :: FilePath -> Order -> Int -> IO ()
+buildTree fname order n = 
+    BT.fromOrderedToFile 4 (fromIntegral n) fname (each things)
+  where things :: [BLeaf Int Int]
+        things = [BLeaf (2*i) i | i <- [0..n-1]]
+        
+lookupBench :: LookupTree Int Int -> Int -> Maybe Int
+lookupBench lt = BT.lookup lt
diff --git a/src/BTree.hs b/src/BTree.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree.hs
@@ -0,0 +1,31 @@
+module BTree ( -- * Basic types
+               BLeaf(..)
+             , Size
+             , Order
+               -- * Building trees
+             , fromOrderedToFile
+             , fromOrderedToByteString
+             , fromUnorderedToFile
+               -- * Looking up in trees
+             , LookupTree
+             , open
+             , fromByteString
+             , lookup
+               -- * Merging trees
+             , mergeTrees
+             , mergeLeaves
+             , sizedProducerForTree
+               -- * Iterating over leaves
+             , walkLeaves
+             ) where
+
+import Prelude hiding (lookup)
+import BTree.Types
+import BTree.Merge
+import BTree.Builder
+import BTree.Lookup
+import BTree.Walk
+import BTree.BuildUnordered
+
+-- | This package provides immutable B* trees targetting large data
+-- sets requiring secondary storage.
diff --git a/src/BTree/BinaryFile.hs b/src/BTree/BinaryFile.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree/BinaryFile.hs
@@ -0,0 +1,88 @@
+module BTree.BinaryFile
+    ( writeWithHeader
+    , readWithHeader
+    ) where
+
+import Control.Monad (when)
+import Control.Error
+import Control.Monad.Trans.Class
+import Control.Applicative
+import Data.Word
+import System.IO
+
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Binary as B
+import qualified Data.Binary.Get as B
+import qualified Data.Binary.Put as B
+import Pipes
+
+-- | This module provides helpers for emitting and reading binary files with
+-- a trailing "header".
+
+-- | An internal data structure placed at the very end of the file which
+-- describes the header and provides a magic number for sanity checking.
+data Epilogue = Epilogue { magic :: Word64
+                         , headerLen :: Word64
+                         }
+              deriving (Show)
+
+epiLength :: Integer
+epiLength = 16
+
+magicNumber :: Word64
+magicNumber = 0xdeadbeef
+
+instance B.Binary Epilogue where
+    get = Epilogue <$> B.getWord64le <*> B.getWord64le
+    put (Epilogue m l) = B.putWord64le m >> B.putWord64le l
+
+-- | Write the produced bytestrings to the file followed by the
+-- returned header
+writeWithHeader :: (MonadIO m, B.Binary hdr)
+                => FilePath
+                -> Producer LBS.ByteString m (hdr, r)
+                -> m r
+writeWithHeader fname prod = do
+    h <- liftIO $ openFile fname WriteMode
+    (hdr, r) <- runEffect $ for prod (liftIO . LBS.hPut h)
+    let encoded = B.encode hdr
+    liftIO $ LBS.hPut h encoded
+    let epi = Epilogue { magic = magicNumber
+                       , headerLen = fromIntegral $ LBS.length encoded }
+    liftIO $ LBS.hPut h (B.encode epi)
+    liftIO $ hClose h
+    return r
+{-# INLINE writeWithHeader #-}
+
+annotate :: Monad m => String -> ExceptT String m a -> ExceptT String m a
+annotate ann = fmapLT ((ann++": ")++)
+
+runGetT :: Monad m => B.Get a -> LBS.ByteString -> ExceptT String m a
+runGetT _get bs = do
+    case B.runGetOrFail _get bs of
+      Left (_, _, e)  -> throwE e
+      Right (_, _, a) -> return a
+
+-- | Read and verify the header from the file, then pass it along with the
+-- file's handle to an action. The file handle sits at the beginning of the
+-- written content when passed to the action.
+readWithHeader :: (MonadIO m, B.Binary hdr)
+               => FilePath
+               -> (hdr -> Handle -> m a)
+               -> ExceptT String m a
+readWithHeader fname action = do
+    h <- liftIO $ openFile fname ReadMode
+    -- read epilogue
+    liftIO $ hSeek h SeekFromEnd (-epiLength)
+    epiBytes <- liftIO (LBS.hGet h $ fromIntegral epiLength)
+    epi <- annotate "Error reading epilogue" (runGetT B.get epiBytes)
+    when (magic epi /= magicNumber) $
+        throwE "BinaryFile.readWithHeader: Bad magic number"
+    -- read header
+    let offset = fromIntegral epiLength + fromIntegral (headerLen epi)
+    liftIO $ hSeek h SeekFromEnd (negate offset)
+    hdrBytes <- liftIO (LBS.hGet h $ fromIntegral $ headerLen epi)
+    hdr <- annotate "Error reading header" (runGetT B.get hdrBytes)
+    -- pass control to action
+    liftIO $ hSeek h AbsoluteSeek 0
+    lift $ action hdr h
diff --git a/src/BTree/BinaryList.hs b/src/BTree/BinaryList.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree/BinaryList.hs
@@ -0,0 +1,90 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module BTree.BinaryList
+    ( BinaryList
+      -- * Construction
+    , toBinaryList
+      -- * Fetching contents
+    , stream
+      -- * Other queries
+    , length
+    , filePath
+    ) where
+
+import Prelude hiding (length)
+import Control.Applicative
+import Control.Monad.Trans.Class
+import Control.Error
+import Data.Word
+import System.IO
+
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Binary as B
+import qualified Data.Binary.Get as B
+import qualified Data.Binary.Put as B
+import qualified Data.Binary.Builder as BB
+import Pipes
+
+import BTree.BinaryFile
+
+-- | A file containing a finite list of binary encoded items
+newtype BinaryList a = BinList FilePath
+                     deriving (Show)
+
+-- | Get the path to the @BinaryList@ file
+filePath :: BinaryList a -> FilePath
+filePath (BinList f) = f
+
+data Header = Header { hdrLength :: Word64 }
+            deriving (Show)
+
+instance B.Binary Header where
+    get = Header <$> B.getWord64le
+    put (Header l) = B.putWord64le l
+
+-- | Encode the items of the given producer
+toBinaryList :: forall m a r. (MonadIO m, B.Binary a)
+             => FilePath -> Producer a m r -> m (BinaryList a, r)
+toBinaryList fname producer = do
+    writeWithHeader fname (go 0 producer BB.empty)
+  where
+    go :: Int -> Producer a m r -> BB.Builder
+       -> Producer LBS.ByteString m (Header, (BinaryList a, r))
+    go !n prod accum = do
+        result <- lift $ next prod
+        case result of
+          Left r -> do
+            let hdr = Header (fromIntegral n)
+            yield $ BB.toLazyByteString accum
+            return (hdr, (BinList fname, r))
+          Right (a, prod')
+            | n `mod` 100 == 0 -> do
+              yield $ BB.toLazyByteString accum
+              go (n+1) prod' (B.execPut $ B.put a)
+            | otherwise ->
+              go (n+1) prod' (accum `BB.append` B.execPut (B.put a))
+{-# INLINE toBinaryList #-}
+
+withHeader :: MonadIO m
+           => BinaryList a -> (Header -> Handle -> m b) -> ExceptT String m b
+withHeader (BinList fname) action = readWithHeader fname action
+
+length :: MonadIO m => BinaryList a -> ExceptT String m Word64
+length bl = withHeader bl $ \hdr _ -> return $ hdrLength hdr
+
+-- | Stream the items out of a @BinaryList@
+stream :: forall m a. (B.Binary a, MonadIO m)
+       => BinaryList a -> ExceptT String m (Producer a m (Either String ()))
+stream bl = withHeader bl readContents
+  where
+    readContents :: Header -> Handle -> m (Producer a m (Either String ()))
+    readContents hdr h = return $ liftIO (LBS.hGetContents h) >>= go (hdrLength hdr)
+
+    go :: Word64 -> LBS.ByteString -> Producer a m (Either String ())
+    go 0  _  = return $ Right ()
+    go !n bs =
+      case B.decodeOrFail bs of
+        Left (_, _, e)    -> return $ Left e
+        Right (bs', _, a) -> yield a >> go (n-1) bs'
+{-# INLINE stream #-}
diff --git a/src/BTree/BuildUnordered.hs b/src/BTree/BuildUnordered.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree/BuildUnordered.hs
@@ -0,0 +1,113 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE FlexibleContexts #-}
+
+module BTree.BuildUnordered
+    ( fromUnorderedToFile ) where
+
+import Control.Monad.Trans.State
+import Control.Error
+import Data.Traversable (forM)
+
+import qualified Data.Binary as B
+import qualified Data.Set as S
+import System.IO
+import System.Directory (removeFile)
+
+import Pipes
+import Pipes.Interleave
+import qualified BTree.BinaryList as BL
+import BTree.Types
+import BTree.Builder
+
+-- | Maximum number of leaf lists to attempt to merge at once.
+-- This is bounded by the maximum file handle count.
+maxChunkMerge :: Int
+maxChunkMerge = 100
+
+tempFilePath :: FilePath -> String -> IO FilePath
+tempFilePath dir template = do
+    (fname, h) <- liftIO $ openTempFile dir template
+    hClose h
+    return fname
+
+-- | Build a B-tree into the given file.
+--
+-- This does not assume that the leaves are produced in order. Instead,
+-- the sorting is handled internally through a simple merge sort. Chunks of
+-- leaves are collected, sorted in memory, and then written to intermediate
+-- trees. At the end these trees are then merged.
+fromUnorderedToFile :: forall m e k r.
+                       (MonadIO m, B.Binary (BLeaf k e), B.Binary k, B.Binary e, Ord k)
+                    => FilePath                   -- ^ Path to scratch directory
+                    -> Int                        -- ^ Maximum chunk size
+                    -> Order                      -- ^ Order of tree
+                    -> FilePath                   -- ^ Output file
+                    -> Producer (BLeaf k e) m r   -- ^ 'Producer' of elements
+                    -> ExceptT String m ()
+fromUnorderedToFile scratch maxChunk order dest producer = {-# SCC fromUnorderedToFile #-} do
+    bList <- lift (execStateT (fillLists producer) []) >>= {-# SCC goMerge #-} goMerge
+    size <- BL.length bList
+    stream <- {-# SCC stream #-} BL.stream bList
+    lift $ {-# SCC buildTree #-} fromOrderedToFile order size dest stream
+    liftIO $ removeFile $ BL.filePath bList
+  where
+    fillLists :: Producer (BLeaf k e) m r -> StateT [BL.BinaryList (BLeaf k e)] m r
+    fillLists prod = {-# SCC fillLists #-} do
+      fname <- liftIO $ tempFilePath scratch "chunk.list"
+      (leaves, rest) <- lift $ takeChunk maxChunk prod
+      (bList, ()) <- lift $ BL.toBinaryList fname $ each $ S.toAscList leaves
+      modify (bList:)
+      case rest of
+        Left r         -> return r
+        Right nextProd -> fillLists nextProd
+
+    goMerge :: [BL.BinaryList (BLeaf k e)] -> ExceptT String m (BL.BinaryList (BLeaf k e))
+    goMerge [l] = return l
+    goMerge ls = do
+      ls'' <- forM (splitChunks maxChunkMerge ls) $ \ls'->do
+        fname <- liftIO $ tempFilePath scratch "merged.list"
+        list <- mergeLists fname ls'
+        liftIO $ mapM_ (removeFile . BL.filePath) ls'
+        return list
+      goMerge ls''
+{-# INLINE fromUnorderedToFile #-}
+
+-- | Split the list into chunks of bounded size and run each through a function
+splitChunks :: Int -> [a] -> [[a]]
+splitChunks chunkSize = go
+  where
+    go [] = []
+    go xs = let (prefix,suffix) = splitAt chunkSize xs
+            in prefix : go suffix
+{-# INLINE splitChunks #-}
+
+throwLeft :: Monad m => m (Either String r) -> m r
+throwLeft action = action >>= either error return
+
+mergeLists :: (Ord a, B.Binary a, MonadIO m)
+           => FilePath -> [BL.BinaryList a] -> ExceptT String m (BL.BinaryList a)
+mergeLists dest lists = do
+    streams <- mapM BL.stream lists
+    let prod = interleave compare (map throwLeft streams)
+    (bList, ()) <- lift $ BL.toBinaryList dest prod
+    return bList
+{-# INLINE mergeLists #-}
+
+-- | Take the first 'n' elements and collect them in a 'Set'. Return
+-- a 'Producer' which will emit the remaining elements (or the return
+-- value).
+takeChunk :: forall m a r. (Monad m, Ord a)
+          => Int
+          -> Producer a m r
+          -> m (S.Set a, Either r (Producer a m r))
+takeChunk n = go n S.empty
+  where
+    go :: Int -> S.Set a -> Producer a m r -> m (S.Set a, Either r (Producer a m r))
+    go 0 s prod = return (s, Right prod)
+    go i s prod = do
+      result <- next prod
+      case result of
+        Left r -> return (s, Left r)
+        Right (a, prod') -> go (i-1) (S.insert a s) prod'
+{-# INLINE takeChunk #-}
diff --git a/src/BTree/Builder.hs b/src/BTree/Builder.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree/Builder.hs
@@ -0,0 +1,237 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module BTree.Builder
+    ( buildNodes, putBS
+    , fromOrderedToFile
+    , fromOrderedToByteString
+    ) where
+
+import Control.Monad.Trans.State.Strict
+import Control.Monad.IO.Class
+import Control.Monad
+
+import Data.Foldable as F
+import qualified Data.Sequence as Seq
+import           Data.Sequence (Seq)
+
+import Data.Ratio
+import Control.Lens
+import System.IO
+
+import qualified Data.Binary as B
+import           Data.Binary (Binary)
+import qualified Data.ByteString.Lazy as LBS
+
+import Pipes
+import Pipes.Core
+import qualified Pipes.Internal as PI
+
+import BTree.Types
+
+-- | A Producer which accepts offsets for the yielded objects in return
+type DiskProducer a = Proxy X () (OnDisk a) a
+
+putBS :: (Binary a, Monad m) => a -> Proxy (OnDisk a) a () LBS.ByteString m r
+putBS a0 = {-# SCC putBS #-} evalStateT (go a0) 0
+  where
+    go a = do
+        s <- get
+        let bs = B.encode a
+        put $! s + fromIntegral (LBS.length bs)
+        lift $ yield bs
+        a' <- lift $ request (OnDisk s)
+        go a'
+{-# INLINE putBS #-}
+
+data DepthState k e = DepthS { -- | nodes to be included in the active node
+                               _dNodes       :: !(Seq (k, OnDisk (BTree k OnDisk e)))
+                               -- | the length of @dNodes@
+                             , _dNodeCount   :: !Int
+                               -- | the desired number of elements to fill the active node
+                             , _dMinFill     :: [Int]
+                             }
+makeLenses ''DepthState
+
+next' :: (Monad m) => Proxy X () a' a m r -> m (Either r (a, a' -> Proxy X () a' a m r))
+next' = go
+  where
+    go p = case p of
+      PI.Request _ fu -> go (fu ())
+      PI.Respond a fu -> return (Right (a, fu))
+      PI.M         m  -> m >>= go
+      PI.Pure    r    -> return (Left r)
+{-# INLINE next' #-}
+
+-- | Compute the optimal node sizes for each stratum of a tree of
+-- given size and order
+optimalFill :: Order -> Size -> [[Int]]
+optimalFill order size = go (fromIntegral size)
+  where
+    go :: Int -> [[Int]]
+    go 0 = error "BTree.Builder.optimalFill: zero size"
+    go n =
+      let nNodes = ceiling (n % order')
+          order' = fromIntegral order :: Int
+          nodes = let (nPerNode, leftover) = n `divMod` nNodes
+                  in zipWith (+) (replicate nNodes nPerNode)
+                                 (replicate leftover 1 ++ repeat 0)
+          rest = case nNodes of
+                   1  -> []
+                   _  -> go nNodes
+      in nodes : rest
+
+-- | Given a producer of a known number of leaves, produces an optimal B-tree.
+-- Technically the size is only an upper bound: the producer may
+-- terminate before providing the given number of leaves although the resulting
+-- tree will break the minimal fill invariant.
+buildNodes :: Monad m
+           => Order -> Size
+           -> DiskProducer (BLeaf k e) m r
+           -> DiskProducer (BTree k OnDisk e) m (BTreeHeader k e)
+buildNodes order size = {-# SCC buildNodes #-}
+    flip evalStateT initialState . loop size
+  where
+    initialState = map (DepthS Seq.empty 0) $ optimalFill order size
+    -- depth=0 denotes the bottom (leaves) of the tree.
+    loop :: Monad m
+         => Size -> DiskProducer (BLeaf k e) m r
+         -> StateT [DepthState k e] (DiskProducer (BTree k OnDisk e) m)
+                   (BTreeHeader k e)
+    loop n producer = do
+        _next <- lift $ lift $ next' producer
+        case _next of
+            Left _  -> do
+                flushAll (size - n)
+            Right _ | n == 0 -> do
+                flushAll (size - n)
+            Right (leaf@(BLeaf k _), producer') -> do
+                -- TODO: Is there a way to check this coercion with the type system?
+                OnDisk offset <- processNode k $ Leaf leaf
+                loop (n-1) $ producer' (OnDisk offset)
+
+    isFilled :: Monad m
+             => StateT [DepthState k e] m Bool
+    isFilled = zoom (singular _head) $ do
+        nodeCount <- use dNodeCount
+        minFill:_ <- use dMinFill
+        return $ nodeCount >= minFill
+
+    emitNode :: Monad m
+             => StateT [DepthState k e] (DiskProducer (BTree k OnDisk e) m)
+                       (OnDisk (BTree k OnDisk e))
+    emitNode = do
+        (k0,node0):nodes <- zoom (singular _head) $ do
+            nodes <- uses dNodes F.toList
+            dNodes .= Seq.empty
+            dNodeCount .= 0
+            dMinFill %= tail
+            return nodes
+
+        --when (null nodes)
+        --  $ error "BTree.Builder.buildNodes: Internal invariant broken: unexpected empty node"
+        let newNode = Node node0 nodes
+        s <- get
+        case s of
+            [_] -> lift $ respond newNode
+            _   -> zoom (singular _tail) $ processNode k0 newNode
+
+    processNode :: Monad m
+                => k -> BTree k OnDisk e
+                -> StateT [DepthState k e]
+                          (DiskProducer (BTree k OnDisk e) m)
+                          (OnDisk (BTree k OnDisk e))
+    processNode startKey tree = do
+        filled <- isFilled
+        when filled $ void $ emitNode
+        offset <- lift $ respond tree
+        zoom _head $ do
+            dNodes %= (Seq.|> (startKey, offset))
+            dNodeCount += 1
+        return offset
+
+    flushAll :: Monad m
+             => Size
+             -> StateT [DepthState k e]
+                       (DiskProducer (BTree k OnDisk e) m)
+                       (BTreeHeader k e)
+    flushAll realSize = do
+        s <- get
+        case s of
+            []   -> error "BTree.Builder.flushAll: We should never get here"
+            [_]  -> do -- We are at the top node, this shouldn't be flushed yet
+                       root <- emitNode
+                       return $ BTreeHeader magic 1 order realSize root
+            d:_  -> do when (not $ Seq.null $ d^.dNodes) $ void $ emitNode
+                       zoom (singular _tail) $ flushAll realSize
+{-# INLINE buildNodes #-}
+
+-- | Produce a bytestring representing the nodes and leaves of the
+-- B-tree and return a suitable header
+buildTree :: (Monad m, Binary e, Binary k)
+          => Order -> Size
+          -> Producer (BLeaf k e) m r
+          -> Producer LBS.ByteString m (BTreeHeader k e)
+buildTree order size producer =
+    dropUpstream $ buildNodes order size (dropUpstream producer) >>~ putBS
+{-# INLINE buildTree #-}
+
+dropUpstream :: Monad m => Proxy X () () b m r -> Proxy X () b' b m r
+dropUpstream = {-# SCC dropUpstream #-} go
+  where
+    go producer = do
+        n <- lift $ next producer
+        case n of
+            Left r               -> return r
+            Right (a, producer') -> respond a >> go producer'
+{-# INLINE dropUpstream #-}
+
+-- | Build a B-tree into the given file.
+--
+-- As the name suggests, this requires that the @Producer@ emits
+-- leaves in ascending key order.
+fromOrderedToFile :: (MonadIO m, Binary e, Binary k)
+                  => Order                     -- ^ Order of tree
+                  -> Size                      -- ^ Maximum tree size
+                  -> FilePath                  -- ^ Output file
+                  -> Producer (BLeaf k e) m r  -- ^ 'Producer' of elements
+                  -> m ()
+fromOrderedToFile order size fname producer = do
+    h <- liftIO $ openFile fname WriteMode
+    liftIO $ LBS.hPut h $ B.encode invalidHeader
+    hdr <- runEffect $ for (buildTree order size producer) $ liftIO . LBS.hPut h
+    liftIO $ hSeek h AbsoluteSeek 0
+    liftIO $ LBS.hPut h $ B.encode hdr
+    liftIO $ hClose h
+    return ()
+  where
+    invalidHeader = BTreeHeader 0 0 0 0 (OnDisk 0)
+{-# INLINE fromOrderedToFile #-}
+
+-- | Build a B-tree into @ByteString@
+--
+-- As the name suggests, this requires that the @Producer@ emits
+-- leaves in ascending key order.
+--
+-- This is primarily used for testing. In particular, note that
+-- this is a bad idea for large trees as the entire contents of the
+-- tree will need to be kept in memory until all leaves have been
+-- added so that the header can be prepended.
+fromOrderedToByteString :: (Monad m, Binary e, Binary k)
+                        => Order                     -- ^ Order of tree
+                        -> Size                      -- ^ Maximum tree size
+                        -> Producer (BLeaf k e) m r  -- ^ 'Producer' of elements
+                        -> m LBS.ByteString
+fromOrderedToByteString order size producer = do
+    (bs, hdr) <- foldR LBS.append LBS.empty id $ buildTree order size producer
+    return $ B.encode hdr `LBS.append` bs
+
+-- | Like @Pipes.Prelude.fold@ but provides returns producer result
+-- in addition to accumulator
+foldR :: Monad m => (x -> a -> x) -> x -> (x -> b) -> Producer a m r -> m (b, r)
+foldR step begin done p0 = loop p0 begin
+  where
+    loop p x = case p of
+        PI.Request _  fu -> loop (fu ()) x
+        PI.Respond a  fu -> loop (fu ()) $! step x a
+        PI.M          m  -> m >>= \p' -> loop p' x
+        PI.Pure    r     -> return (done x, r)
diff --git a/src/BTree/Lookup.hs b/src/BTree/Lookup.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree/Lookup.hs
@@ -0,0 +1,47 @@
+module BTree.Lookup ( LookupTree
+                    , open
+                    , fromByteString
+                    , lookup
+                    ) where
+
+import Prelude hiding (lookup)
+import Control.Error
+import Control.Lens hiding (children)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as LBS
+import Data.Binary
+import System.IO.MMap
+import BTree.Types
+
+fetch :: (Binary a) => LookupTree k e -> OnDisk a -> a
+fetch lt (OnDisk offset) =
+    decode $ LBS.fromStrict $ BS.drop (fromIntegral offset) (lt^.ltData)
+
+-- | Read a B-tree from a 'ByteString' produced by 'BTree.Builder'
+fromByteString :: LBS.ByteString -> Either String (LookupTree k e)
+fromByteString bs = do
+    (rest, _, hdr) <- fmapL (\(_,_,e)->e) $ decodeOrFail bs
+    validateHeader hdr
+    return $ LookupTree (LBS.toStrict rest) hdr
+
+-- | Open a B-tree file.
+open :: FilePath -> IO (Either String (LookupTree k e))
+open fname = runExceptT $ do
+    d <- fmapLT show $ tryIO $ mmapFileByteString fname Nothing
+    ExceptT $ return $ fromByteString (LBS.fromStrict d)
+
+-- | Lookup a key in a B-tree.
+lookup :: (Binary k, Binary e, Ord k)
+       => LookupTree k e -> k -> Maybe e
+lookup lt k = go $ fetch lt (lt ^. ltHeader . btRoot)
+  where
+    go (Leaf (BLeaf k' e))
+      | k' == k     = Just e
+      | otherwise   = Nothing
+    go (Node c0 []) = go $ fetch lt c0 -- is this case necessary?
+    go (Node c0 children@((k0,_):_))
+      | k < k0      = go $ fetch lt c0
+      | otherwise   =
+          case takeWhile (\(k',_)->k' <= k) children of
+            []  -> Nothing
+            xs  -> go $ fetch lt $ snd $ last xs
diff --git a/src/BTree/Merge.hs b/src/BTree/Merge.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree/Merge.hs
@@ -0,0 +1,63 @@
+{-# LANGUAGE TemplateHaskell, BangPatterns, GeneralizedNewtypeDeriving #-}
+
+module BTree.Merge ( mergeTrees
+                   , mergeLeaves
+                   , sizedProducerForTree
+                   ) where
+
+import Prelude hiding (sum)
+import Control.Applicative
+import Data.Foldable
+import Data.Function (on)
+import Control.Monad.State hiding (forM_)
+import Data.Binary
+import Control.Lens
+import Pipes
+import Pipes.Interleave
+
+import BTree.Types
+import BTree.Builder
+import BTree.Walk
+
+-- | Merge trees' leaves taking ordered leaves from a set of producers.
+--
+-- Each producer must be annotated with the number of leaves it is
+-- expected to produce. The size of the resulting tree will be at most
+-- the sum of these sizes.
+mergeLeaves :: (MonadIO m, Functor m, Binary k, Binary e, Ord k)
+            => (e -> e -> m e)               -- ^ merge operation on elements
+            -> Order                         -- ^ order of merged tree
+            -> FilePath                      -- ^ name of output file
+            -> [(Size, Producer (BLeaf k e) m ())]   -- ^ producers of leaves to merge
+            -> m ()
+mergeLeaves append destOrder destFile producers = do
+    let size = sum $ map fst producers
+    fromOrderedToFile destOrder size destFile $
+      mergeM (compare `on` key) doAppend (map snd producers)
+  where
+    doAppend (BLeaf k e) (BLeaf _ e') = BLeaf k <$> append e e'
+    key (BLeaf k _) = k
+{-# INLINE mergeLeaves #-}
+
+-- | Merge several 'LookupTrees'
+--
+-- This is a convenience function for merging several trees already on
+-- disk. For a more flexible interface, see 'mergeLeaves'.
+mergeTrees :: (MonadIO m, Functor m, Binary k, Binary e, Ord k)
+           => (e -> e -> m e)        -- ^ merge operation on elements
+           -> Order                  -- ^ order of merged tree
+           -> FilePath               -- ^ name of output file
+           -> [LookupTree k e]       -- ^ trees to merge
+           -> m ()
+mergeTrees append destOrder destFile trees = do
+    mergeLeaves append destOrder destFile
+    $ map sizedProducerForTree trees
+{-# INLINE mergeTrees #-}
+
+-- | Get a sized 'Producer' suitable for 'mergeLeaves' from a 'LookupTree'
+sizedProducerForTree :: (Monad m, Binary k, Binary e)
+                     => LookupTree k e   -- ^ a tree
+                     -> (Size, Producer (BLeaf k e) m ())
+                                         -- ^ a sized 'Producer' suitable for passing
+                                         -- to 'mergeLeaves'
+sizedProducerForTree lt = (lt ^. ltHeader . btSize, void $ walkLeaves lt)
diff --git a/src/BTree/Types.hs b/src/BTree/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree/Types.hs
@@ -0,0 +1,105 @@
+{-# LANGUAGE DeriveGeneric, FlexibleContexts, TemplateHaskell, UndecidableInstances, StandaloneDeriving #-}
+
+module BTree.Types where
+
+import Data.Binary
+import GHC.Generics
+import Control.Monad (when)
+import Control.Applicative
+import Control.Lens
+import Data.Int
+import qualified Data.ByteString as BS
+
+-- | An offset within the stream
+type Offset = Int64
+
+-- | The number of entries in a B-tree
+type Size = Word64
+
+-- | The maximum number of children of a B-tree inner node
+type Order = Word64
+
+-- | 'OnDisk a' is a reference to an object of type 'a' on disk.
+-- The offset does not include the header; e.g. the first object after
+-- the header is located at offset 0.
+newtype OnDisk a = OnDisk Offset
+                 deriving (Show, Eq, Ord)
+
+instance Binary (OnDisk a) where
+    get = OnDisk <$> get
+    {-# INLINE get #-}
+    put (OnDisk off) = put off
+    {-# INLINE put #-}
+
+-- | A tree leaf (e.g. key/value pair)
+data BLeaf k e = BLeaf !k !e
+               deriving (Generic)
+
+deriving instance (Show k, Show e) => Show (BLeaf k e)
+
+-- | This only compares on the keys
+instance (Eq k) => Eq (BLeaf k e) where
+    BLeaf a _ == BLeaf b _ = a == b
+
+-- | This only compares on the keys
+instance Ord k => Ord (BLeaf k e) where
+    compare (BLeaf a _) (BLeaf b _) = compare a b
+    {-# INLINE compare #-}
+
+instance (Binary k, Binary e) => Binary (BLeaf k e) where
+    get = BLeaf <$> get <*> get
+    {-# INLINE get #-}
+    put (BLeaf k e) = put k >> put e
+    {-# INLINE put #-}
+
+-- | 'BTree k f e' is a B* tree of key type 'k' with elements of type 'e'.
+-- Subtree references are contained within a type 'f'
+--
+-- The Node constructor contains a left child, and a list of key/child pairs
+-- where each child's keys are greater than or equal to the given key.
+data BTree k f e = Node (f (BTree k f e)) [(k, f (BTree k f e))]
+                 | Leaf !(BLeaf k e)
+                 deriving (Generic)
+
+deriving instance (Show e, Show k, Show (f (BTree k f e))) => Show (BTree k f e)
+deriving instance (Eq e, Eq k, Eq (f (BTree k f e))) => Eq (BTree k f e)
+
+instance (Binary k, Binary (f (BTree k f e)), Binary e)
+  => Binary (BTree k f e) where
+    get = do typ <- getWord8
+             case typ of
+               0 -> Node <$> get <*> get
+               1 -> bleaf <$> get <*> get
+               _ -> fail "BTree.Types/get: Unknown node type"
+      where bleaf k v = Leaf (BLeaf k v)
+    {-# INLINE get #-}
+
+    put (Node e0 es)         = putWord8 0 >> put e0 >> put es
+    put (Leaf (BLeaf k0 e))  = putWord8 1 >> put k0 >> put e
+    {-# INLINE put #-}
+
+magic :: Word64
+magic = 0xdeadbeefbbbbcccc
+
+-- | B-tree file header
+data BTreeHeader k e = BTreeHeader { _btMagic   :: !Word64
+                                   , _btVersion :: !Word64
+                                   , _btOrder   :: !Order
+                                   , _btSize    :: !Size
+                                   , _btRoot    :: !(OnDisk (BTree k OnDisk e))
+                                   }
+                 deriving (Show, Eq, Generic)
+makeLenses ''BTreeHeader
+
+instance Binary (BTreeHeader k e)
+
+validateHeader :: BTreeHeader k e -> Either String ()
+validateHeader hdr = do
+    when (hdr^.btMagic /= magic) $ Left "Invalid magic number"
+    when (hdr^.btVersion > 1) $ Left "Invalid version"
+
+-- | A read-only B-tree for lookups
+data LookupTree k e = LookupTree { _ltData    :: !BS.ByteString
+                                 , _ltHeader  :: !(BTreeHeader k e)
+                                 }
+makeLenses ''LookupTree
diff --git a/src/BTree/Walk.hs b/src/BTree/Walk.hs
new file mode 100644
--- /dev/null
+++ b/src/BTree/Walk.hs
@@ -0,0 +1,53 @@
+module BTree.Walk ( walkLeaves
+                  , walkNodes
+                  , walkNodesWithOffset
+                  ) where
+
+import BTree.Types
+import qualified Data.ByteString.Lazy as LBS
+import Pipes
+import qualified Pipes.Prelude as PP
+import Data.Binary
+import Data.Binary.Get (runGetOrFail)
+import Control.Lens
+
+-- If we only look at leaves keys will increase monotonically as we
+-- progress through the file.
+
+filterLeaves :: Monad m => Pipe (BTree k OnDisk v) (BLeaf k v) m r
+filterLeaves = do
+    a <- await
+    case a of
+      Leaf leaf  -> yield leaf
+      _          -> return ()
+    filterLeaves
+{-# INLINE filterLeaves #-}
+
+-- | Iterate over the leaves of the given tree in ascending key order.
+walkLeaves :: (Binary k, Binary v, Monad m)
+           => LookupTree k v
+           -> Producer (BLeaf k v) m (LBS.ByteString, Maybe String)
+walkLeaves b = walkNodes b >-> filterLeaves
+{-# INLINE walkLeaves #-}
+
+-- | Iterate over the nodes and leaves of the given tree. These aren't
+-- necessarily sorted.
+walkNodes :: (Binary k, Binary v, Monad m)
+          => LookupTree k v
+          -> Producer (BTree k OnDisk v) m (LBS.ByteString, Maybe String)
+walkNodes b = walkNodesWithOffset b >-> PP.map snd
+{-# INLINE walkNodes #-}
+
+walkNodesWithOffset :: (Binary k, Binary v, Monad m)
+                    => LookupTree k v
+                    -> Producer (Offset, BTree k OnDisk v) m (LBS.ByteString, Maybe String)
+walkNodesWithOffset = go 0 . view (ltData . to LBS.fromStrict)
+  where go offset bs =
+            case runGetOrFail get bs of
+              Left (rest,_,err)  -> return (rest, Just err)
+              Right (rest,o,a)   -> do
+                yield (offset, a)
+                if LBS.null rest
+                  then return (rest, Nothing)
+                  else go (offset+o) rest
+{-# INLINE walkNodesWithOffset #-}
