Skip to content

wireform-ndjson

wireform-ndjson adds newline-delimited JSON framing on top of aeson. Log aggregators, analytics pipelines, and many HTTP streaming APIs emit one JSON value per line; this package splits those lines efficiently, parses each with aeson, and exposes both batch and streaming APIs so memory stays flat on large inputs. Use it when you already model records with ToJSON/FromJSON and only need the NDJSON container format.

CapabilityWhy it matters
NDJSON framing on aesonReuse existing JSON instances without a second schema
Streaming decodedecodeStream calls back per line with bounded memory
Concurrent producer/consumerdecodeConcurrent parses and dispatches across a TBQueue
SIMD newline scanningWireform.FFI.findByteBS finds \n in 16-byte chunks
Typed batch helpersdecodeRecords and encodeRecords for Vector workflows

Each value becomes one JSON object followed by a newline. The encoder uses Wireform.Builder to avoid unnecessary intermediate buffers.

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
import GHC.Generics (Generic)
import Data.Aeson (ToJSON)
import Data.ByteString (ByteString)
import Data.Text (Text)
import Data.Vector (Vector)
import qualified Data.Vector as V
import NDJSON.Encode (encodeRecords)
data Event = Event
{ eventId :: !Int
, eventName :: !Text
} deriving stock (Generic, ToJSON)
writeLog :: Vector Event -> ByteString
writeLog = encodeRecords

When lines arrive from a socket or a file read loop, decodeStream parses one line at a time and invokes your handler. Empty lines are skipped.

import Data.ByteString (ByteString)
import NDJSON.Decode (decodeStream)
import qualified Data.Aeson as Aeson
processLines :: ByteString -> IO (Either String ())
processLines bs =
decodeStream bs $ \val -> do
case Aeson.fromJSON val of
Aeson.Success ev -> handleEvent (ev :: Event)
Aeson.Error err -> print err

When the entire blob fits in memory, decodeRecords returns a Vector of parsed rows in one pass.

import Data.ByteString (ByteString)
import Data.Vector (Vector)
import NDJSON.Decode (decodeRecords)
loadEvents :: ByteString -> Either String (Vector Event)
loadEvents = decodeRecords

decodeConcurrent runs a producer thread that scans newlines and enqueues parsed Aeson.Value values into a TBQueue, while the same call processes each value through your callback on the consumer side. Pass the queue depth to control how many parsed lines can buffer between producer and consumer.

import Data.ByteString (ByteString)
import NDJSON.Decode (decodeConcurrent)
import qualified Data.Aeson as Aeson
processConcurrent :: ByteString -> Int -> IO (Either String ())
processConcurrent bs queueDepth =
decodeConcurrent bs queueDepth $ \val -> do
case Aeson.fromJSON val of
Aeson.Success ev -> handleEvent (ev :: Event)
Aeson.Error err -> print err

For untyped pipelines, NDJSON.Decode.decode returns Vector Aeson.Value, and NDJSON.Encode.encode accepts the same.

wireform-ndjson vs aeson + manual line splitting

Section titled “wireform-ndjson vs aeson + manual line splitting”
wireform-ndjson vs aeson + manual newline splitting wireform-ndjson vs aeson + manual newline splitting lower is better · µs · ghc-9.8.4 on darwin-aarch64, criterion 1.6.5. Today the SIMD newline scanner doesn't yet outperform `BS.split '\n'` on these inputs; both paths are within 10%. 0 250 500 750 1000 5.18 4.95 518 508 3.85 3.84 426 382 encode 10 rows encode 1000 rows decode 10 rows decode 1000 rows wireform-ndjson aeson + lines wireform-ndjson vs aeson + manual newline splitting lower is better · µs · ghc-9.8.4 on darwin-aarch64, criterion 1.6.5. Today the SIMD newline scanner doesn't yet outperform `BS.split '\n'` on these inputs; both paths are within 10%. 0 250 500 750 1000 5.18 4.95 518 508 3.85 3.84 426 382 encode 10 rows encode 1000 rows decode 10 rows decode 1000 rows wireform-ndjson aeson + lines
Operationwireform-ndjsonaeson + linesratio
encode 10 rows5.18 µs4.95 µs0.96x
encode 1000 rows518 µs508 µs0.98x
decode 10 rows3.85 µs3.84 µs0.100x
decode 1000 rows426 µs382 µs0.90x

Last run 2026-06-27 11:35:55 UTC. ghc-9.8.4 on darwin-aarch64, criterion 1.6.5. Today the SIMD newline scanner doesn’t yet outperform BS.split '\n' on these inputs; both paths are within 10%..

Performance is within ~10% of raw aeson with manual newline splitting. wireform-ndjson’s value is in the typed API and proper line-framing semantics, not raw speed.

The chart and table above are regenerated by wireform-stats from wireform-ndjson/bench-results/summary/ndjson-vs-aeson-lines.json — the same source the README chart is built from.

ModuleRole
NDJSON.Decodedecode, decodeStream, decodeRecords, decodeConcurrent
NDJSON.Encodeencode, encodeRecords
NDJSON.DeriveHelpers aligned with the wireform deriver ecosystem