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

A complete guide to working with JSONL (JSON Lines) files in Rust. Learn to read, write, parse, and stream JSONL data using serde_json, BufReader, rayon for parallelism, and tokio for async I/O.

Last updated: February 2026

Why Rust for JSONL?

Rust is an excellent choice for JSONL processing when performance, memory safety, and correctness matter. Its ownership model eliminates data races at compile time, its zero-cost abstractions let you write high-level iterator chains that compile down to tight loops, and serde_json is one of the fastest JSON parsers available in any language. If you are building a data pipeline that needs to process millions of JSONL records per second, Rust gives you the control to achieve that without sacrificing safety.

JSONL (JSON Lines) stores one JSON object per line, making it ideal for streaming, append-only logging, and large dataset processing. Rust's BufReader reads files line by line without loading the entire file into memory, and its iterator model fits JSONL's line-oriented structure perfectly. In this guide, you will learn how to read and write JSONL with BufReader, parse records into strongly typed structs with serde, process records in parallel with rayon, handle async I/O with tokio, and build robust error handling with Result and the ? operator.

Reading JSONL Files with BufReader

Rust's standard library provides BufReader for efficient buffered file reading. Combined with the lines() iterator, it reads JSONL files one line at a time with minimal memory overhead. Each line is parsed independently, making this approach suitable for files of any size.

The simplest approach wraps a File in BufReader, iterates over lines(), and parses each line with serde_json. This keeps only one line in memory at a time, regardless of file size.

Basic JSONL Reading with 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; // Skip empty lines
}
match serde_json::from_str::<Value>(trimmed) {
Ok(value) => records.push(value),
Err(e) => {
eprintln!("Skipping invalid JSON at line {}: {}", line_num + 1, e);
}
}
}
Ok(records)
}
fn main() -> std::io::Result<()> {
let records = read_jsonl("data.jsonl")?;
println!("Loaded {} records", records.len());
if let Some(first) = records.first() {
println!("First record: {}", first);
}
Ok(())
}

Add serde and serde_json to your Cargo.toml. The derive feature enables the Serialize and Deserialize derive macros for defining typed structs.

Cargo.toml Dependencies
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Optional: for parallel processing
rayon = "1.10"
# Optional: for async I/O
tokio = { version = "1", features = ["full"] }

Type-Safe Parsing with serde_json

One of Rust's greatest strengths for JSONL processing is serde's derive macros, which let you deserialize each JSON line directly into a strongly typed struct. The compiler verifies that your code handles the data correctly, catching type mismatches, missing fields, and structural errors at compile time rather than at runtime.

Define a struct with Deserialize and use serde_json::from_str to parse each JSONL line into it. Missing or malformed fields produce clear error messages at parse time instead of causing panics later in your program.

Typed Struct Deserialization
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(())
}

When JSONL records have varying schemas, use serde_json::Value for flexible access. You can index into values with bracket notation or use the as_str(), as_i64() methods for safe type conversion.

Dynamic Parsing with 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)?;
// Access fields dynamically
if let Some(name) = value.get("name").and_then(Value::as_str) {
println!("Name: {}", name);
}
if let Some(age) = value.get("age").and_then(Value::as_i64) {
println!("Age: {}", age);
}
// Check if a field exists
if value.get("email").is_some() {
println!("Has email field");
}
}
Ok(())
}

Writing JSONL Files in Rust

Writing JSONL is the inverse of reading: serialize each record to a JSON string, append a newline, and write it to a file. Using BufWriter and serde_json::to_string ensures both correctness and performance.

Serialize each struct with serde_json::to_string() and write it followed by a newline. The Serialize derive macro handles conversion from your Rust types to JSON automatically.

Basic JSONL Writing
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!("Wrote {} records", records.len());
Ok(())
}

For writing large JSONL files, wrap the File in a BufWriter to reduce the number of system calls. This batches small writes into larger buffer flushes, significantly improving throughput.

Buffered Writing for Large 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(())
}

Parallel Processing with rayon

Rust's rayon crate provides data parallelism with minimal code changes. By calling par_iter() instead of iter(), rayon automatically distributes work across all CPU cores using a work-stealing thread pool. This is especially effective for CPU-bound JSONL transformations where parsing and processing dominate I/O time.

Parallel Processing with 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> {
// Step 1: Read all lines into memory
let file = File::open(input_path)?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().collect::<Result<_, _>>()?;
// Step 2: Parse and transform in 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();
// Step 3: Write results
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!("Processed {} records in parallel", count);
Ok(())
}

This pattern reads all lines, processes them in parallel with rayon's par_iter(), and writes the results sequentially. The parallel step handles parsing and transformation across all available CPU cores. For files that fit in memory, this can achieve near-linear speedup with the number of cores. For very large files, consider chunking the input to balance memory usage and parallelism.

Async I/O with tokio

When your JSONL processing involves network I/O, such as reading from HTTP endpoints or writing to remote storage, tokio's async runtime lets you overlap I/O waits with computation. The tokio::io::AsyncBufReadExt trait provides an async lines() method that mirrors the synchronous BufReader API.

Async I/O with 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!("Read {} async records", records.len());
write_jsonl_async("output.jsonl", &records).await?;
Ok(())
}

The async approach is most beneficial when combined with network operations. For pure file I/O on local disks, synchronous BufReader is often faster because async adds overhead for task scheduling. Use tokio when you need to read JSONL from HTTP streams, S3 buckets, or other network sources where I/O latency dominates.

Error Handling: Result & the ? Operator

Rust's Result type and the ? operator provide a clean, composable error handling pattern for JSONL processing. Instead of try-catch blocks, each fallible operation returns a Result that you can propagate with ? or handle locally with match. This makes error paths explicit and impossible to accidentally ignore.

Error Handling: Result & the ? Operator
use serde::Deserialize;
use std::fs::File;
use std::io::{BufRead, BufReader};
use thiserror::Error;
#[derive(Error, Debug)]
enum JsonlError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error at line {line}: {source}")]
Parse {
line: usize,
source: serde_json::Error,
},
#[error("Validation error at line {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: "name cannot be empty".into(),
});
}
if record.value < 0.0 {
return Err(JsonlError::Validation {
line,
message: format!("value must be non-negative, got {}", record.value),
});
}
Ok(())
}
fn process_jsonl_with_errors(
path: &str,
) -> Result<Vec<Record>, JsonlError> {
let file = File::open(path)?; // Io error auto-converted 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?; // Propagate I/O errors
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!("Successfully processed {} records", records.len()),
Err(JsonlError::Io(e)) => eprintln!("File error: {}", e),
Err(JsonlError::Parse { line, source }) => {
eprintln!("JSON error at line {}: {}", line, source);
}
Err(JsonlError::Validation { line, message }) => {
eprintln!("Validation error at line {}: {}", line, message);
}
}
}

This pattern defines a custom error enum with thiserror that covers all failure modes: I/O errors, JSON parse errors with line numbers, and domain-specific validation errors. The #[from] attribute enables automatic conversion from std::io::Error, and the ? operator propagates errors up the call stack. Each error variant carries enough context to produce a clear diagnostic message, making it easy to pinpoint exactly which line caused the problem.

Rust Crates for JSON Processing

The Rust ecosystem offers several JSON parsing crates with different performance characteristics. serde_json is the standard choice, while simd-json and sonic-rs push the performance envelope using SIMD instructions.

serde_json

Standard

The de facto standard for JSON in Rust. It integrates seamlessly with serde's derive macros for zero-boilerplate serialization. Excellent for most JSONL workloads with strong type safety, comprehensive error messages, and broad ecosystem support.

simd-json

Fastest

A Rust port of simdjson that uses SIMD instructions to parse JSON at multiple gigabytes per second. It requires mutable input buffers and provides a different API from serde_json, but it can be 2-4x faster for parsing-heavy workloads. Best suited for high-throughput pipelines.

sonic-rs

SIMD + serde

A SIMD-accelerated JSON library with a serde-compatible API. It aims to combine the speed of simd-json with the ergonomics of serde_json. Supports both lazy and eager parsing modes, making it a good choice when you need performance without rewriting your code.

Try Our Free JSONL Tools

Don't want to write code? Use our free online tools to view, validate, and convert JSONL files right in your browser.

Work with JSONL Files Online

View, validate, and convert JSONL files up to 1GB right in your browser. No uploads required, 100% private.

Frequently Asked Questions

JSONL in Rust β€” serde_json, BufReader & Streaming Pattern...