JSONL in Go: bufio, json.Decoder & Concurrency
A complete guide to working with JSONL (JSON Lines) files in Go. Learn to read, write, stream, and concurrently process JSONL data using the standard library's bufio, encoding/json, and goroutines.
Last updated: February 2026
Why Go for JSONL?
Go is an excellent choice for processing JSONL files, especially when performance and concurrency matter. The standard library provides everything you need: bufio for efficient line-by-line I/O, encoding/json for parsing and serialization, and goroutines for parallel processing. There are no third-party dependencies required. Go's compiled nature means your JSONL pipeline will run significantly faster than equivalent Python or Node.js scripts, often processing millions of lines per second on modest hardware.
JSONL (JSON Lines) stores one JSON object per line, making it a natural fit for Go's streaming I/O model. Instead of loading an entire file into memory, you can read and process records one at a time using a scanner or decoder. Combined with Go's lightweight goroutines and channels, you can build concurrent JSONL pipelines that saturate all available CPU cores. In this guide, you will learn how to read JSONL with bufio.Scanner, stream with json.Decoder, write JSONL efficiently, process records concurrently, and handle errors robustly.
Reading JSONL Files with bufio.Scanner
The most straightforward way to read JSONL in Go is with bufio.Scanner. It reads a file line by line, and you parse each line with json.Unmarshal. This approach gives you full control over buffer sizes and error handling.
Open the file, create a scanner, and iterate line by line. Each line is parsed into a map or struct with json.Unmarshal. This keeps memory usage proportional to a single record, not the entire file.
package mainimport ("bufio""encoding/json""fmt""log""os")func main() {file, err := os.Open("data.jsonl")if err != nil {log.Fatal(err)}defer file.Close()var records []map[string]anyscanner := bufio.NewScanner(file)// Increase buffer for lines longer than 64KBscanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)for scanner.Scan() {var record map[string]anyif err := json.Unmarshal(scanner.Bytes(), &record); err != nil {log.Printf("skipping invalid JSON: %v", err)continue}records = append(records, record)}if err := scanner.Err(); err != nil {log.Fatal(err)}fmt.Printf("Loaded %d records\n", len(records))}
For production code, parse JSONL records into typed Go structs instead of generic maps. This provides compile-time type safety, better performance, and clearer code. Define your struct with json tags to control field mapping.
package mainimport ("bufio""encoding/json""fmt""log""os")type User struct {ID int `json:"id"`Name string `json:"name"`Email string `json:"email"`Age int `json:"age,omitempty"`}func readUsers(path string) ([]User, error) {file, err := os.Open(path)if err != nil {return nil, fmt.Errorf("open file: %w", err)}defer file.Close()var users []Userscanner := bufio.NewScanner(file)lineNum := 0for scanner.Scan() {lineNum++var u Userif err := json.Unmarshal(scanner.Bytes(), &u); err != nil {return nil, fmt.Errorf("line %d: %w", lineNum, err)}users = append(users, u)}if err := scanner.Err(); err != nil {return nil, fmt.Errorf("scanner error: %w", err)}return users, nil}func main() {users, err := readUsers("users.jsonl")if err != nil {log.Fatal(err)}for _, u := range users {fmt.Printf("%s (%s)\n", u.Name, u.Email)}}
Streaming with json.Decoder
For high-performance streaming, json.Decoder reads directly from an io.Reader without intermediate line splitting. It handles buffering internally and is the recommended approach for large JSONL files or network streams where you want minimal allocations.
json.NewDecoder wraps any io.Reader and decodes JSON values sequentially. Each call to Decode reads exactly one JSON object. When the stream ends, it returns io.EOF. This is more efficient than bufio.Scanner + json.Unmarshal because it avoids copying each line into a separate buffer.
package mainimport ("encoding/json""errors""fmt""io""log""os")type LogEntry struct {Timestamp string `json:"timestamp"`Level string `json:"level"`Message string `json:"message"`Service string `json:"service"`}func processJSONLStream(path string) error {file, err := os.Open(path)if err != nil {return fmt.Errorf("open: %w", err)}defer file.Close()decoder := json.NewDecoder(file)var count intfor {var entry LogEntryerr := decoder.Decode(&entry)if errors.Is(err, io.EOF) {break}if err != nil {return fmt.Errorf("decode record %d: %w", count+1, err)}count++// Process each record as it's decodedif entry.Level == "ERROR" {fmt.Printf("[%s] %s: %s\n",entry.Timestamp, entry.Service, entry.Message)}}fmt.Printf("Processed %d log entries\n", count)return nil}func main() {if err := processJSONLStream("logs.jsonl"); err != nil {log.Fatal(err)}}
Writing JSONL Files in Go
Writing JSONL in Go is straightforward: serialize each record with json.Marshal, append a newline byte, and write to a file. For best performance, wrap the file writer in a bufio.Writer to reduce system calls.
Use json.Marshal to serialize each record to JSON bytes, then write them followed by a newline. This approach is simple and works well for small to medium files.
package mainimport ("encoding/json""fmt""log""os")type Product struct {ID int `json:"id"`Name string `json:"name"`Price float64 `json:"price"`}func main() {products := []Product{{ID: 1, Name: "Laptop", Price: 999.99},{ID: 2, Name: "Mouse", Price: 29.99},{ID: 3, Name: "Keyboard", Price: 79.99},}file, err := os.Create("products.jsonl")if err != nil {log.Fatal(err)}defer file.Close()for _, p := range products {data, err := json.Marshal(p)if err != nil {log.Printf("skip marshal error: %v", err)continue}data = append(data, '\n')if _, err := file.Write(data); err != nil {log.Fatal(err)}}fmt.Printf("Wrote %d records to products.jsonl\n", len(products))}
For high-throughput writing, use json.NewEncoder with a bufio.Writer. The encoder appends a newline after each Encode call automatically, and the buffered writer batches small writes into larger system calls. Always call Flush before closing the file.
package mainimport ("bufio""encoding/json""fmt""log""os")type Event struct {Type string `json:"type"`Timestamp int64 `json:"timestamp"`Payload map[string]any `json:"payload"`}func writeEvents(path string, events []Event) error {file, err := os.Create(path)if err != nil {return fmt.Errorf("create file: %w", err)}defer file.Close()writer := bufio.NewWriter(file)encoder := json.NewEncoder(writer)// Disable HTML escaping for cleaner outputencoder.SetEscapeHTML(false)for i, event := range events {if err := encoder.Encode(event); err != nil {return fmt.Errorf("encode event %d: %w", i, err)}}// Flush buffered data to fileif err := writer.Flush(); err != nil {return fmt.Errorf("flush: %w", err)}fmt.Printf("Wrote %d events to %s\n", len(events), path)return nil}func main() {events := []Event{{Type: "click", Timestamp: 1700000001, Payload: map[string]any{"x": 120, "y": 450}},{Type: "view", Timestamp: 1700000002, Payload: map[string]any{"page": "/home"}},}if err := writeEvents("events.jsonl", events); err != nil {log.Fatal(err)}}
Concurrent JSONL Processing with Goroutines
Go's goroutines and channels make it easy to build concurrent JSONL pipelines. The fan-out pattern reads records from a file, distributes them across multiple worker goroutines, and collects results through a channel. This is ideal for CPU-bound transformations on large JSONL files.
package mainimport ("bufio""encoding/json""fmt""log""os""runtime""sync")type Record struct {ID int `json:"id"`Data map[string]any `json:"data"`}type Result struct {ID int `json:"id"`Processed bool `json:"processed"`Score int `json:"score"`}func process(r Record) Result {// Simulate CPU-bound workscore := len(r.Data) * r.IDreturn Result{ID: r.ID, Processed: true, Score: score}}func main() {file, err := os.Open("records.jsonl")if err != nil {log.Fatal(err)}defer file.Close()numWorkers := runtime.NumCPU()jobs := make(chan Record, numWorkers*2)results := make(chan Result, numWorkers*2)// Start workersvar wg sync.WaitGroupfor i := 0; i < numWorkers; i++ {wg.Add(1)go func() {defer wg.Done()for record := range jobs {results <- process(record)}}()}// Close results channel when all workers finishgo func() {wg.Wait()close(results)}()// Read file and send records to workersgo func() {scanner := bufio.NewScanner(file)for scanner.Scan() {var r Recordif err := json.Unmarshal(scanner.Bytes(), &r); err != nil {log.Printf("skip invalid line: %v", err)continue}jobs <- r}close(jobs)}()// Collect and write resultsoutFile, err := os.Create("results.jsonl")if err != nil {log.Fatal(err)}defer outFile.Close()writer := bufio.NewWriter(outFile)encoder := json.NewEncoder(writer)var count intfor result := range results {if err := encoder.Encode(result); err != nil {log.Printf("encode error: %v", err)}count++}writer.Flush()fmt.Printf("Processed %d records with %d workers\n", count, numWorkers)}
This fan-out pattern distributes JSONL records across goroutines equal to the number of CPU cores. The buffered channels prevent goroutines from blocking on send or receive. The sync.WaitGroup ensures all workers finish before the results channel is closed. For I/O-bound workloads such as API calls, you can increase numWorkers beyond the CPU count. Note that output order is not guaranteed with concurrent processing.
Error Handling Best Practices
Robust JSONL processing in Go requires careful error handling. Real-world JSONL files can contain malformed lines, encoding issues, or unexpectedly large records. This pattern provides line-level error tracking, configurable skip-on-error behavior, and summary reporting.
package mainimport ("bufio""encoding/json""fmt""log""os")type ParseError struct {Line intErr errorRaw string}func (e *ParseError) Error() string {return fmt.Sprintf("line %d: %v", e.Line, e.Err)}type JSONLReader struct {scanner *bufio.ScannerlineNum intErrors []ParseErrorSkipped intSuccess int}func NewJSONLReader(file *os.File) *JSONLReader {scanner := bufio.NewScanner(file)scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)return &JSONLReader{scanner: scanner}}func (r *JSONLReader) ReadAll(target any) error {slice, ok := target.(*[]map[string]any)if !ok {return fmt.Errorf("target must be *[]map[string]any")}for r.scanner.Scan() {r.lineNum++line := r.scanner.Bytes()// Skip empty linesif len(line) == 0 {continue}var record map[string]anyif err := json.Unmarshal(line, &record); err != nil {r.Errors = append(r.Errors, ParseError{Line: r.lineNum,Err: err,Raw: string(line),})r.Skipped++continue}*slice = append(*slice, record)r.Success++}return r.scanner.Err()}func (r *JSONLReader) Summary() string {return fmt.Sprintf("Lines: %d | Success: %d | Skipped: %d | Errors: %d",r.lineNum, r.Success, r.Skipped, len(r.Errors),)}func main() {file, err := os.Open("data.jsonl")if err != nil {log.Fatal(err)}defer file.Close()reader := NewJSONLReader(file)var records []map[string]anyif err := reader.ReadAll(&records); err != nil {log.Fatal(err)}fmt.Println(reader.Summary())for _, e := range reader.Errors {log.Printf("Parse error at %s", e.Error())}}
This JSONLReader struct tracks line numbers, collects parse errors with their raw content, and reports a summary of successes and failures. The 10MB scanner buffer handles unusually large lines without panicking. In production, you might extend this with context.Context support for cancellation, a maximum error threshold that aborts processing, or structured logging with slog.
Go Packages for JSONL Processing
Go's standard library provides all the building blocks you need for JSONL processing. Here are the three core approaches and when to use each one.
bufio.Scanner
FlexibleThe standard line-by-line reader. Pairs with json.Unmarshal for explicit parsing. Best when you need control over buffer sizes, want to skip or inspect raw lines, or need line numbers for error reporting. Handles lines up to your configured buffer size.
json.Decoder
RecommendedStreaming JSON decoder that reads directly from an io.Reader. More efficient than Scanner + Unmarshal because it avoids copying data. Ideal for large files and network streams. Handles back-to-back JSON values automatically without needing explicit newline splitting.
json.Encoder
WritingStreaming JSON encoder that writes to an io.Writer. Automatically appends a newline after each Encode call, making it perfect for JSONL output. Combine with bufio.Writer for high-throughput writing. Supports SetEscapeHTML and SetIndent configuration.
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.