JSONL in Rust: serde_json, BufReader & Zero-Cost Abstractions

Een complete handleiding voor het werken met JSONL (JSON Lines) bestanden in Rust. Leer hoe je JSONL-data leest, schrijft, parst en streamt met serde_json, BufReader, rayon voor parallellisme en tokio voor async I/O.

Laatst bijgewerkt: februari 2026

Waarom Rust voor JSONL?

Rust is een uitstekende keuze voor JSONL-verwerking wanneer prestaties, geheugenveiligheid en correctheid belangrijk zijn. Het ownership-model elimineert data races tijdens het compileren, de zero-cost abstractions laten je high-level iterator-chains schrijven die compileren naar strakke lussen, en serde_json is een van de snelste JSON-parsers die beschikbaar zijn in welke taal dan ook. Als je een data-pipeline bouwt die miljoenen JSONL-records per seconde moet verwerken, geeft Rust je de controle om dat te bereiken zonder veiligheid op te offeren.

JSONL (JSON Lines) slaat één JSON-object per regel op, wat het ideaal maakt voor streaming, append-only logging en verwerking van grote datasets. Rust's BufReader leest bestanden regel voor regel zonder het hele bestand in het geheugen te laden, en het iterator-model past perfect bij JSONL's regelgeoriënteerde structuur. In deze handleiding leer je hoe je JSONL leest en schrijft met BufReader, records parst naar sterk getypeerde structs met serde, records parallel verwerkt met rayon, async I/O afhandelt met tokio, en robuuste foutafhandeling bouwt met Result en de ?-operator.

JSONL-bestanden lezen met BufReader

Rust's standaardbibliotheek biedt BufReader voor efficiënt gebufferd bestanden lezen. Gecombineerd met de lines()-iterator leest het JSONL-bestanden één regel tegelijk met minimale geheugenoverhead. Elke regel wordt onafhankelijk geparseerd, waardoor deze aanpak geschikt is voor bestanden van elke grootte.

De eenvoudigste aanpak wikkelt een File in BufReader, itereert over lines() en parst elke regel met serde_json. Dit houdt slechts één regel in het geheugen, ongeacht de bestandsgrootte.

Basis JSONL lezen met BufReader
use std::fs::File;
use std::io::{BufRead, BufReader};
use serde_json::Value;
fn read_jsonl(path: &str) -> std::io::Result<Vec<Value>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut records = Vec::new();
for (line_num, line) in reader.lines().enumerate() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue; // Sla lege regels over
}
match serde_json::from_str::<Value>(trimmed) {
Ok(value) => records.push(value),
Err(e) => {
eprintln!("Ongeldige JSON overgeslagen op regel {}: {}", line_num + 1, e);
}
}
}
Ok(records)
}
fn main() -> std::io::Result<()> {
let records = read_jsonl("data.jsonl")?;
println!("{} records geladen", records.len());
if let Some(first) = records.first() {
println!("Eerste record: {}", first);
}
Ok(())
}

Voeg serde en serde_json toe aan je Cargo.toml. De derive-feature schakelt de Serialize en Deserialize derive-macro's in voor het definiëren van getypeerde structs.

Cargo.toml afhankelijkheden
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Optioneel: voor parallelle verwerking
rayon = "1.10"
# Optioneel: voor async I/O
tokio = { version = "1", features = ["full"] }

Type-veilig parseren met serde_json

Een van Rust's grootste sterktes voor JSONL-verwerking zijn serde's derive-macro's, waarmee je elke JSON-regel direct kunt deserialiseren naar een sterk getypeerde struct. De compiler verifieert dat je code de data correct afhandelt en vangt type-mismatches, ontbrekende velden en structurele fouten op tijdens het compileren in plaats van tijdens runtime.

Definieer een struct met Deserialize en gebruik serde_json::from_str om elke JSONL-regel erin te parseren. Ontbrekende of misvormde velden produceren duidelijke foutmeldingen bij het parseren in plaats van panics later in je programma.

Getypeerde struct-deserialisatie
use serde::Deserialize;
use std::fs::File;
use std::io::{BufRead, BufReader};
#[derive(Debug, Deserialize)]
struct LogEntry {
timestamp: String,
level: String,
message: String,
#[serde(default)]
metadata: Option<serde_json::Value>,
}
fn read_typed_jsonl(path: &str) -> anyhow::Result<Vec<LogEntry>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let entry: LogEntry = serde_json::from_str(trimmed)?;
entries.push(entry);
}
Ok(entries)
}
fn main() -> anyhow::Result<()> {
let entries = read_typed_jsonl("logs.jsonl")?;
for entry in &entries {
println!("[{}] {}: {}", entry.level, entry.timestamp, entry.message);
}
Ok(())
}

Wanneer JSONL-records wisselende schema's hebben, gebruik serde_json::Value voor flexibele toegang. Je kunt waarden indexeren met bracket-notatie of de as_str(), as_i64() methoden gebruiken voor veilige typeconversie.

Dynamisch parseren met serde_json::Value
use serde_json::Value;
use std::fs::File;
use std::io::{BufRead, BufReader};
fn process_dynamic_jsonl(path: &str) -> anyhow::Result<()> {
let file = File::open(path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: Value = serde_json::from_str(trimmed)?;
// Velden dynamisch benaderen
if let Some(name) = value.get("name").and_then(Value::as_str) {
println!("Naam: {}", name);
}
if let Some(age) = value.get("age").and_then(Value::as_i64) {
println!("Leeftijd: {}", age);
}
// Controleer of een veld bestaat
if value.get("email").is_some() {
println!("Heeft e-mailveld");
}
}
Ok(())
}

JSONL-bestanden schrijven in Rust

JSONL schrijven is het omgekeerde van lezen: serialiseer elk record naar een JSON-string, voeg een newline toe en schrijf het naar een bestand. Door BufWriter en serde_json::to_string te gebruiken zorg je voor zowel correctheid als prestaties.

Serialiseer elke struct met serde_json::to_string() en schrijf het gevolgd door een newline. De Serialize derive-macro verzorgt de conversie van je Rust-types naar JSON automatisch.

Basis JSONL schrijven
use serde::Serialize;
use std::fs::File;
use std::io::Write;
#[derive(Serialize)]
struct Record {
id: u64,
name: String,
score: f64,
}
fn write_jsonl(path: &str, records: &[Record]) -> std::io::Result<()> {
let mut file = File::create(path)?;
for record in records {
let json = serde_json::to_string(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
writeln!(file, "{}", json)?;
}
Ok(())
}
fn main() -> std::io::Result<()> {
let records = vec![
Record { id: 1, name: "Alice".into(), score: 95.5 },
Record { id: 2, name: "Bob".into(), score: 87.3 },
Record { id: 3, name: "Charlie".into(), score: 92.1 },
];
write_jsonl("output.jsonl", &records)?;
println!("{} records geschreven", records.len());
Ok(())
}

Voor het schrijven van grote JSONL-bestanden wikkel je de File in een BufWriter om het aantal systeemaanroepen te verminderen. Dit groepeert kleine schrijfacties in grotere bufferflushes, wat de doorvoer aanzienlijk verbetert.

Gebufferd schrijven voor grote output
use serde::Serialize;
use std::fs::File;
use std::io::{BufWriter, Write};
#[derive(Serialize)]
struct Event {
timestamp: u64,
event_type: String,
payload: serde_json::Value,
}
fn write_buffered_jsonl(path: &str, events: &[Event]) -> std::io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
for event in events {
serde_json::to_writer(&mut writer, event)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
writer.write_all(b"\n")?;
}
writer.flush()?;
Ok(())
}

Parallelle verwerking met rayon

Rust's rayon-crate biedt data-parallellisme met minimale codewijzigingen. Door par_iter() aan te roepen in plaats van iter(), verdeelt rayon automatisch het werk over alle CPU-cores met behulp van een work-stealing thread pool. Dit is vooral effectief voor CPU-intensieve JSONL-transformaties waarbij parsing en verwerking de I/O-tijd domineren.

Parallelle verwerking met rayon
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
#[derive(Debug, Deserialize, Serialize)]
struct Record {
id: u64,
text: String,
#[serde(default)]
word_count: Option<usize>,
}
fn parallel_process_jsonl(
input_path: &str,
output_path: &str,
) -> anyhow::Result<usize> {
// Stap 1: Lees alle regels in het geheugen
let file = File::open(input_path)?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().collect::<Result<_, _>>()?;
// Stap 2: Parseer en transformeer parallel
let processed: Vec<Record> = lines
.par_iter()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
serde_json::from_str::<Record>(line.trim()).ok()
})
.map(|mut record| {
record.word_count = Some(record.text.split_whitespace().count());
record
})
.collect();
// Stap 3: Schrijf resultaten
let file = File::create(output_path)?;
let mut writer = BufWriter::new(file);
let count = processed.len();
for record in &processed {
serde_json::to_writer(&mut writer, record)?;
writer.write_all(b"\n")?;
}
writer.flush()?;
Ok(count)
}
fn main() -> anyhow::Result<()> {
let count = parallel_process_jsonl("input.jsonl", "output.jsonl")?;
println!("{} records parallel verwerkt", count);
Ok(())
}

Dit patroon leest alle regels, verwerkt ze parallel met rayon's par_iter() en schrijft de resultaten sequentieel. De parallelle stap verzorgt parsing en transformatie over alle beschikbare CPU-cores. Voor bestanden die in het geheugen passen kan dit bijna lineaire versnelling bereiken met het aantal cores. Voor zeer grote bestanden overweeg dan de input in chunks te verdelen om geheugengebruik en parallellisme in balans te houden.

Async I/O met tokio

Wanneer je JSONL-verwerking netwerk-I/O omvat, zoals het lezen van HTTP-endpoints of het schrijven naar externe opslag, laat tokio's async runtime je I/O-wachttijden overlappen met berekeningen. De tokio::io::AsyncBufReadExt trait biedt een async lines()-methode die de synchrone BufReader API spiegelt.

Async I/O met tokio
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ApiRecord {
id: u64,
url: String,
status: String,
}
async fn read_jsonl_async(path: &str) -> anyhow::Result<Vec<ApiRecord>> {
let file = File::open(path).await?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
let mut records = Vec::new();
while let Some(line) = lines.next_line().await? {
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
continue;
}
let record: ApiRecord = serde_json::from_str(&trimmed)?;
records.push(record);
}
Ok(records)
}
async fn write_jsonl_async(
path: &str,
records: &[ApiRecord],
) -> anyhow::Result<()> {
use tokio::io::AsyncWriteExt;
let mut file = File::create(path).await?;
for record in records {
let json = serde_json::to_string(record)?;
file.write_all(json.as_bytes()).await?;
file.write_all(b"\n").await?;
}
file.flush().await?;
Ok(())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let records = read_jsonl_async("api_logs.jsonl").await?;
println!("{} async records gelezen", records.len());
write_jsonl_async("output.jsonl", &records).await?;
Ok(())
}

De async-aanpak is het meest voordelig in combinatie met netwerkoperaties. Voor pure bestands-I/O op lokale schijven is synchrone BufReader vaak sneller omdat async overhead toevoegt voor taakplanning. Gebruik tokio wanneer je JSONL moet lezen van HTTP-streams, S3-buckets of andere netwerkbronnen waar I/O-latentie dominant is.

Foutafhandeling: Result & de ?-operator

Rust's Result-type en de ?-operator bieden een schoon, composeerbaar foutafhandelingspatroon voor JSONL-verwerking. In plaats van try-catch-blokken retourneert elke feilbare operatie een Result die je kunt propageren met ? of lokaal kunt afhandelen met match. Dit maakt foutpaden expliciet en onmogelijk om per ongeluk te negeren.

Foutafhandeling: Result & de ?-operator
use serde::Deserialize;
use std::fs::File;
use std::io::{BufRead, BufReader};
use thiserror::Error;
#[derive(Error, Debug)]
enum JsonlError {
#[error("I/O-fout: {0}")]
Io(#[from] std::io::Error),
#[error("JSON-parseerfout op regel {line}: {source}")]
Parse {
line: usize,
source: serde_json::Error,
},
#[error("Validatiefout op regel {line}: {message}")]
Validation {
line: usize,
message: String,
},
}
#[derive(Debug, Deserialize)]
struct Record {
id: u64,
name: String,
value: f64,
}
fn validate_record(record: &Record, line: usize) -> Result<(), JsonlError> {
if record.name.is_empty() {
return Err(JsonlError::Validation {
line,
message: "naam mag niet leeg zijn".into(),
});
}
if record.value < 0.0 {
return Err(JsonlError::Validation {
line,
message: format!("waarde moet niet-negatief zijn, kreeg {}", record.value),
});
}
Ok(())
}
fn process_jsonl_with_errors(
path: &str,
) -> Result<Vec<Record>, JsonlError> {
let file = File::open(path)?; // I/O-fout automatisch geconverteerd via #[from]
let reader = BufReader::new(file);
let mut records = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let line_num = idx + 1;
let line = line?; // Propageer I/O-fouten
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let record: Record = serde_json::from_str(trimmed)
.map_err(|e| JsonlError::Parse { line: line_num, source: e })?;
validate_record(&record, line_num)?;
records.push(record);
}
Ok(records)
}
fn main() {
match process_jsonl_with_errors("data.jsonl") {
Ok(records) => println!("{} records succesvol verwerkt", records.len()),
Err(JsonlError::Io(e)) => eprintln!("Bestandsfout: {}", e),
Err(JsonlError::Parse { line, source }) => {
eprintln!("JSON-fout op regel {}: {}", line, source);
}
Err(JsonlError::Validation { line, message }) => {
eprintln!("Validatiefout op regel {}: {}", line, message);
}
}
}

Dit patroon definieert een aangepaste fout-enum met thiserror die alle faalscenario's dekt: I/O-fouten, JSON-parseerfouten met regelnummers en domein-specifieke validatiefouten. Het #[from]-attribuut maakt automatische conversie van std::io::Error mogelijk, en de ?-operator propageert fouten door de call stack. Elke foutvariant draagt voldoende context om een duidelijke diagnostische melding te produceren, waardoor het gemakkelijk is om precies te bepalen welke regel het probleem veroorzaakte.

Rust-crates voor JSON-verwerking

Het Rust-ecosysteem biedt verschillende JSON-parsing crates met verschillende prestatiekenmerken. serde_json is de standaardkeuze, terwijl simd-json en sonic-rs de prestatiegrens verleggen met SIMD-instructies.

serde_json

Standaard

De feitelijke standaard voor JSON in Rust. Het integreert naadloos met serde's derive-macro's voor boilerplate-vrije serialisatie. Uitstekend voor de meeste JSONL-werklasten met sterke typeveiligheid, uitgebreide foutmeldingen en brede ecosysteemondersteuning.

simd-json

Snelst

Een Rust-port van simdjson die SIMD-instructies gebruikt om JSON te parseren met meerdere gigabytes per seconde. Het vereist muteerbare invoerbuffers en biedt een andere API dan serde_json, maar het kan 2-4x sneller zijn voor parsing-intensieve werklasten. Het meest geschikt voor pipelines met hoge doorvoer.

sonic-rs

SIMD + serde

Een SIMD-versnelde JSON-bibliotheek met een serde-compatibele API. Het streeft ernaar de snelheid van simd-json te combineren met de ergonomie van serde_json. Ondersteunt zowel lazy als eager parsing-modi, wat het een goede keuze maakt wanneer je prestaties nodig hebt zonder je code te herschrijven.

Probeer onze gratis JSONL-tools

Wil je geen code schrijven? Gebruik onze gratis online tools om JSONL-bestanden te bekijken, valideren en converteren direct in je browser.

Werk online met JSONL-bestanden

Bekijk, valideer en converteer JSONL-bestanden tot 1GB direct in je browser. Geen uploads nodig, 100% privé.

Veelgestelde vragen

JSONL in Rust — serde_json, BufReader & streaming pattern...