Parse Log Lines Fast
Problem. A service writes one line per request, and you want to read a few fields of millions of lines, or only check that every line is well formed. You want it fast, and you want typed values, not a tree to search.
Describe the line, generate a Go parser from the grammar, and call ParseAST (typed values) or Recognize (no values).
A line looks like this:
2026-10-09T11:12:41Z INFO api GET /users/42 200 12ms
// logs.pego
type Time = node[string]
type Level = node[string]
type Service = node[string]
type Method = node[string]
type Path = node[string]
type Status = node[string]
type Millis = node[string]
type Line = {
Time Time
Level Level
Service Service
Method Method
Path Path
Status Status
Millis Millis
}
type Log = { Lines []Line }
@start
rule main Log = ls:line* $$ => Log{Lines: $ls.Value}
rule line Line = (
t:time " " l:severity " " s:service " " m:method " " p:path " " st:status " " ms:millis "ms\n"
) => Line{Time: $t, Level: $l, Service: $s, Method: $m, Path: $p, Status: $st, Millis: $ms}
rule time Time = ([0-9] #repeat(4) "-" [0-9] #repeat(2) "-" [0-9] #repeat(2) "T" [0-9] #repeat(2) ":" [0-9] #repeat(2) ":" [0-9] #repeat(2) "Z") #atomic
rule severity Level = ("DEBUG" / "INFO" / "WARN" / "ERROR") #atomic
rule service Service = ([a-z\-]+) #atomic
rule method Method = ("GET" / "POST" / "PUT" / "DELETE") #atomic
rule path Path = ([^ \n]+) #atomic
rule status Status = ([0-9] #repeat(3)) #atomic
rule millis Millis = ([0-9]+) #atomic
Generate the parser with -types (Go types for the grammar's types and ParseAST) and -recognize (Recognize).
The tool is recorded in the module with go get -tool github.com/ornew/pego/cmd/pego, and pego gen creates the output
directory:
go generate ./...
// main.go
package main
import (
"fmt"
"io"
"os"
"strconv"
"example.com/logs/logparser"
)
//go:generate go tool pego gen -g logs.pego -module example.com/logs/logparser -types -recognize -o logparser
func main() {
data, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
log, err := logparser.ParseAST(string(data))
if err != nil {
fmt.Fprintln(os.Stderr, err) // for example 3:12: syntax error: expected ...
os.Exit(1)
}
count := map[string]int{}
var slowest *logparser.Line
slowestMs := -1
for _, l := range log.Lines {
count[l.Level.Value]++
ms, _ := strconv.Atoi(l.Millis.Value) // the grammar guarantees digits
if ms > slowestMs {
slowest, slowestMs = l, ms
}
}
for _, level := range []string{"DEBUG", "INFO", "WARN", "ERROR"} {
fmt.Printf("%-5s %d\n", level, count[level])
}
if slowest != nil {
fmt.Printf("slowest: %s %s %s (%sms)\n", slowest.Method.Value, slowest.Path.Value, slowest.Status.Value, slowest.Millis.Value)
}
}
With this sample.log:
2026-10-09T11:12:41Z INFO api GET /users/42 200 12ms
2026-10-09T11:12:41Z INFO api POST /users 201 48ms
2026-10-09T11:12:42Z WARN billing GET /invoices?page=2 200 930ms
2026-10-09T11:12:43Z ERROR api GET /users/7 500 4ms
2026-10-09T11:12:43Z DEBUG cache DELETE /sessions/abc 204 1ms
go run . < sample.log
DEBUG 1
INFO 2
WARN 1
ERROR 1
slowest: GET /invoices?page=2 200 (930ms)
A line that does not match is reported with its position, and nothing is counted:
printf '2026-10-09T11:12:41Z INFO api GET /x 200 12ms\n2026-10-09T11:12:41Z INFO api GET /x 20 12ms\n' | go run .
2:40: syntax error: expected [0-9]
How It Works
- A generated parser is the fast one.
pego genwrites a Go package with no dependency on PEGO. With-types, every rule here has a Go type of its own, soParseASTbuilds*Linevalues directly, without the generic nodesParsewould build first. The values are plain structs:l.Level.Value,l.Millis.Value. - Strings stay strings.
Millisis a string node, so itsValueis the matched digits, and you convert (strconv.Atoi) only the fields you use. Recognizeonly checks. If all you need is "is this file well formed",logparser.Recognize(text)returns the same error asParseASTwithout building anything.
How Fast
A benchmark in the same directory parses 100,000 lines (5.7 MB) of the same shape, once per iteration, with each entry
point in turn (b.Loop(), b.SetBytes(len(input))): p.Parse and p.Parse(input, pego.RecognizeOnly()) of the engine
(pego.CompileSource), and Parse, ParseAST and Recognize of the generated parser. This is the output of
go test -bench . -benchmem on an Apple M3 Max with Go 1.27.1, rounded, with the columns that are not used here left
out:
BenchmarkEngineParse 100 ms 57 MB/s 205.0 MB/op 709789 allocs/op
BenchmarkEngineRecognizeOnly 52 ms 110 MB/s 22.9 MB/op 40 allocs/op
BenchmarkGeneratedParse 48 ms 118 MB/s 195.6 MB/op 708688 allocs/op
BenchmarkGeneratedParseAST 24 ms 240 MB/s 81.7 MB/op 3192 allocs/op
BenchmarkGeneratedRecognize 14 ms 414 MB/s 45.7 MB/op 4 allocs/op
Generating the parser halves the time, and typed values and recognition halve it again. Numbers differ between machines and inputs; measure your own grammar and input (see Debugging and Profiling for profiling a parse).
Variations and Limits
- Several formats. Put each in its own grammar and package, or use
ParseRuleof the generated parser to start at another rule. - Lines you do not want to fail on. Parse each line separately (a grammar whose
mainis oneline), or add error recovery so that a bad line becomes anerrorvalue and parsing goes on. - More input than memory. The generated parser needs the whole input in memory; use the engine and
#stream. - Without a generated file.
p.Parse(text, pego.RecognizeOnly())is the engine's recognition mode, and the typed structs can be replaced by theFieldandElemsof*pego.Node.
See Code generation for what generated parsers support and Running parsers for recognition mode.