Skip to content

JSONL-Dateien (JSON Lines) lesen und schreiben snippet

JSON Lines — ein JSON-Wert pro physischer Zeile — ist das Streaming-Datenformat: append-only-Logs, Bulk-Export/-Import, LLM-Trainingsdateien.

JSON Lines — ein JSON-Wert pro physischer Zeile — ist das Streaming-Datenformat: append-only-Logs, Bulk-Export/-Import, LLM-Trainingsdateien. Die Regel, die das trägt: keine Zeilenumbrüche innerhalb eines Datensatzes, damit ein Leser Zeile für Zeile verarbeiten kann, ohne die ganze Datei zu parsen. Die Falle ist der Schreiber: JSON mit literalen Zeilenumbrüchen in Strings zu serialisieren, bricht das Format still — ein strikter JSON-Serializer escaped sie als \n, handgebauter String-Zusammenbau aber nicht. Die Leser-Falle ist die Leerzeile — überspringen oder abbrechen, aber entscheiden.

Runnable recipe · 12 languages
Files & Streamsjsonljson-linesndjsonfilesstreamingserialization

Every language

12 languages, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
import { appendFileSync, createReadStream } from 'node:fs';
import { createInterface } from 'node:readline/promises';

// write: one JSON.stringify per record — it escapes any embedded newline
appendFileSync('events.jsonl', JSON.stringify({ id: 1, msg: 'multi\nline' }) + '\n');

// read: readline + one JSON.parse per line — never parse the whole file
const rl = createInterface({ input: createReadStream('events.jsonl') });
for await (const line of rl) {
  if (!line.trim()) continue; // blank line: decided, skipped
  const rec = JSON.parse(line);
}

JSON.stringify turns an embedded newline into the two bytes \n — the line framing survives. Hand-assembly (msg + '|' + ts) writes the raw byte and silently corrupts every reader after it. The interface also yields a final line with no trailing \n.

TSTypeScript
import { createReadStream, createWriteStream } from 'node:fs';
import { createInterface } from 'node:readline/promises';

// write: append loop — each record serialized on its own line
export async function writeJsonl(path: string, rows: unknown[]): Promise<void> {
  const out = createWriteStream(path, { flags: 'a' });
  for (const row of rows) out.write(JSON.stringify(row) + '\n');
  await new Promise<void>((res, rej) => out.end((err) => (err ? rej(err) : res())));
}

// read: AsyncGenerator yields one parsed record at a time
export async function* readJsonl<T>(path: string): AsyncGenerator<T> {
  const rl = createInterface({ input: createReadStream(path) });
  for await (const line of rl) {
    if (line.trim()) yield JSON.parse(line) as T;
  }
}

The generator keeps memory flat — a 10 GB file streams the same as a 10 KB one because nothing accumulates. T is a claim, not a check: the cast trusts the file, so validate records at the boundary when the file is foreign.

GoGo
import (
	"bufio"
	"bytes"
	"encoding/json"
	"os"
)

type Record struct {
	ID  int    `json:"id"`
	Msg string `json:"msg"`
}

// write: one compact Marshal per record, one line each
func appendJSONL(path string, recs []Record) error {
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer f.Close()
	w := bufio.NewWriter(f)
	for _, r := range recs {
		b, err := json.Marshal(r) // compact: no raw newlines inside
		if err != nil {
			return err
		}
		w.Write(b)
		w.WriteByte('\n')
	}
	return w.Flush()
}

// read: scanner lines, one Unmarshal per line, record per callback
func readJSONL(path string, fn func(Record) error) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // raise the 64KB line cap
	for sc.Scan() {
		line := bytes.TrimSpace(sc.Bytes())
		if len(line) == 0 {
			continue // blank line: decided, skipped
		}
		var r Record
		if err := json.Unmarshal(line, &r); err != nil {
			return err
		}
		if err := fn(r); err != nil {
			return err
		}
	}
	return sc.Err()
}

json.Marshal escapes <, >, & to \u003c… by default (HTML escaping) — still valid JSON, but raw grepping surprises; an Encoder with SetEscapeHTML(false) writes them plain when that matters. And Scanner's default line cap is 64 KB: one long minified record ends the loop with bufio.ErrTooLong unless Buffer() raises it.

RsRust
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};

#[derive(Serialize, Deserialize)]
struct Record {
    id: u32,
    msg: String,
}

fn append_jsonl(path: &str, recs: &[Record]) -> std::io::Result<()> {
    let mut f = OpenOptions::new().create(true).append(true).open(path)?;
    for r in recs {
        serde_json::to_writer(&mut f, r)?; // compact — no newlines inside
        f.write_all(b"\n")?;
    }
    Ok(())
}

fn read_jsonl(path: &str) -> std::io::Result<Vec<Record>> {
    let mut out = Vec::new();
    for line in BufReader::new(OpenOptions::new().read(true).open(path)?).lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue; // blank line: decided, skipped
        }
        out.push(serde_json::from_str(&line).map_err(std::io::Error::other)?);
    }
    Ok(out)
}

The ndjson ecosystem crate exists, but this 10-line hand-roll IS the idiomatic Rust — to_writer is already compact and lines() is already the framing. serde errors map through io::Error::other because the lines() iterator only speaks io::Result.

PHPPHP
// write: 'ab' = append + binary-safe; one json_encode per row
$f = fopen('events.jsonl', 'ab');
foreach ($rows as $row) {
    // JSON_UNESCAPED_UNICODE keeps real UTF-8 bytes instead of \uXXXX escapes
    fwrite($f, json_encode($row, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n");
}
fclose($f);

// read: fgets line loop; JSON_THROW_ON_ERROR makes failure loud
$f = fopen('events.jsonl', 'rb');
while (($line = fgets($f)) !== false) {
    $line = trim($line);
    if ($line === '') continue; // blank line fails json_decode — guard it
    $row = json_decode($line, true, 512, JSON_THROW_ON_ERROR);
}
fclose($f);

Blank lines fail json_decode — it returns null, which is ALSO a valid decoded value, so without JSON_THROW_ON_ERROR the failure is silent. The everyday malformed line is the blank one a stray double newline leaves behind: trim and skip.

PyPython
import json

# write: one dumps per row — compact by default, one line per record
with open('events.jsonl', 'a', encoding='utf-8') as f:
    for row in rows:
        f.write(json.dumps(row) + '\n')

# read: the 3-liner — iterate, skip blanks, parse
with open('events.jsonl', encoding='utf-8') as f:
    records = [json.loads(line) for line in f if line.strip()]

json.dumps escapes embedded newlines to \n, so the line framing is safe by construction. `for line in f` is lazy — a big file streams without slurping. The jsonlines package exists, but this loop is the standard.

C#C#
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;

record Row(int Id, string Msg);

// one static options pair — reflection metadata caches attach to the INSTANCE
static readonly JsonSerializerOptions Json = new(); // default IS compact

static void WriteJsonl(string path, IEnumerable<Row> rows)
{
    using var writer = new StreamWriter(path, append: true);
    foreach (var row in rows)
    {
        writer.WriteLine(JsonSerializer.Serialize(row, Json)); // one line each
    }
}

static IEnumerable<Row> ReadJsonl(string path) =>
    File.ReadLines(path) // lazy — streams the file
        .Where(l => !string.IsNullOrWhiteSpace(l))
        .Select(l => JsonSerializer.Deserialize<Row>(l, Json)!);

File.ReadLines is the lazy one — ReadAllLines materializes a string[] of every line first. And REUSE one JsonSerializerOptions instance: the first (de)serialize with a given instance builds and caches metadata — a fresh options per row re-pays that every line.

JvJava
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;

class Jsonl {
    // ONE static mapper — thread-safe after config, expensive to build
    private static final ObjectMapper MAPPER = new ObjectMapper();

    record Row(int id, String msg) {}

    static void write(Path path, List<Row> rows) throws IOException {
        try (var out = Files.newBufferedWriter(path,
                StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
            for (Row row : rows) {
                out.write(MAPPER.writeValueAsString(row)); // compact
                out.write('\n');
            }
        }
    }

    static List<Row> read(Path path) throws IOException {
        try (BufferedReader in = Files.newBufferedReader(path)) {
            return in.lines()
                    .filter(s -> !s.isBlank())
                    .map(Jsonl::readRow) // checked IOException → wrap for the stream
                    .toList();
        }
    }

    private static Row readRow(String line) {
        try {
            return MAPPER.readValue(line, Row.class);
        } catch (IOException e) {
            throw new UncheckedIOException(line, e); // the offending line is the context
        }
    }
}

A single ObjectMapper instance is thread-safe once configured — build it once (static), never per row. readValue throws the checked IOException, which a stream lambda cannot propagate; UncheckedIOException carries the offending line out with it.

SwSwift
import Foundation

struct Row: Codable { let id: Int; let msg: String }

// write: default JSONEncoder IS compact — set no formatting options
func appendJsonl(_ rows: [Row], to url: URL) throws {
    let encoder = JSONEncoder()
    let handle = try FileHandle(forWritingTo: url)
    defer { try? handle.close() }
    _ = try handle.seekToEnd() // append, don't clobber
    for row in rows {
        try handle.write(contentsOf: encoder.encode(row))
        try handle.write(contentsOf: Data("\n".utf8)) // the separator
    }
}

// read: split into lines, decode each — split drops empties by default
func readJsonl(_ url: URL) throws -> [Row] {
    let decoder = JSONDecoder()
    let text = try String(contentsOf: url, encoding: .utf8)
    return try text.split(separator: "\n")
        .map { try decoder.decode(Row.self, from: Data($0.utf8)) }
}

Compact output requires no formatting options — the default JSONEncoder IS compact; .prettyPrinted would break the framing (encode through singleValueContainer() when a custom Encodable insists on pretty). String(contentsOf:) loads the whole file — fine for configs, use a buffered line reader for multi-GB logs.

KtKotlin
import com.fasterxml.jackson.databind.ObjectMapper
import java.io.File

data class Row(val id: Int, val msg: String)

private val mapper = ObjectMapper() // one instance, reused — same JVM rule as Java

// write: appendText per record + '\n' — the one-line append
fun writeJsonl(path: String, rows: List<Row>) {
    rows.forEach { row ->
        File(path).appendText(mapper.writeValueAsString(row) + "\n")
    }
}

// read: lazy sequence — records parse as you iterate, memory stays flat
fun readJsonl(path: String) = sequence {
    File(path).bufferedReader().use { reader ->
        while (true) {
            val line = reader.readLine() ?: break
            if (line.isNotBlank()) yield(mapper.readValue(line, Row::class.java))
        }
    }
}

appendText + '\n' per record is the one-liner append — it reopens the file each call, fine for log-style writes, hoist a BufferedWriter for hot loops. The sequence {} builder reads lazily so a huge file stays flat; yield must be called directly in the builder body — a regular lambda (forEach on a materialized list) cannot contain it.

RbRuby
require 'json'

# write: JSON.generate — compact, exactly one line per record
File.open('events.jsonl', 'a') do |f|
  rows.each { |row| f.puts JSON.generate(row) }
end

# read: each_line + one JSON.parse per line
File.open('events.jsonl').each_line do |line|
  next if line.strip.empty? # blank line: decided, skipped
  row = JSON.parse(line)
end

JSON.pretty_generate would BREAK the format — a pretty record spans many physical lines, so the one-line-per-record rule dies. JSON.generate (or to_json) is always compact; puts supplies the separator.

ZigZig
const std = @import("std");

const Row = struct { id: u32, msg: []const u8 };

// write: one minified Stringify per record + the '\n' separator
pub fn writeJsonl(path: []const u8, rows: []const Row) !void {
    const file = try std.fs.cwd().createFile(path, .{ .truncate = false });
    defer file.close();
    try file.seekFromEnd(0);
    var bw = std.io.bufferedWriter(file.writer());
    const w = bw.writer();
    for (rows) |row| {
        try std.json.Stringify.value(row, .{}, w); // compact by default
        try w.writeAll("\n");
    }
    try bw.flush();
}

// read: line iterator + one parse per line, arena reset between records
pub fn readJsonl(gpa: std.mem.Allocator, path: []const u8) !void {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();
    var br = std.io.bufferedReader(file.reader());
    const r = br.reader();

    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();
    var line_buf: [64 * 1024]u8 = undefined; // cap on ONE record
    while (try r.readUntilDelimiterOrEof(&line_buf, '\n')) |line| {
        _ = arena.reset(.retain_capacity); // free the previous record
        const row = try std.json.parseFromSliceLeaky(Row, arena.allocator(), line, .{});
        std.debug.print("{d} {s}\n", .{ row.id, row.msg });
    }
}

The arena-per-line pattern keeps memory flat: parseFromSliceLeaky parks every allocation of one record in the arena, and reset(.retain_capacity) reclaims it all before the next line — no per-field frees, no growth. Stringify.value is minified by default; the 64 KB line buffer is the record cap (StreamTooLong past it).