Parsing Logs

Regular Expressions in Practice

Chapter 8 ยท Parsing Logs

Log files are where patterns earn their keep. Each line is a small record written by a program, so the lines share a layout, but the layout is plain text and there is no ready-made parser. In this chapter you will turn raw lines into dictionaries, cope with lines that do not fit, and finish with a short report. It pulls together named groups, re.VERBOSE, fullmatch and the iteration tools from the earlier chapters.

What was run
As before, every snippet was executed and the comments show what Python printed.

Read the line first

Start by looking at the structure, not by writing a pattern. A line in our example log looks like this:

2026-11-02 09:15:03 ERROR [auth] Login failed for user=ann ip=10.0.0.5

There are five parts: a date, a time, a level in capitals, a source in square brackets, and then a free-text message that may hold key=value pairs. The first four parts are rigid; the message is loose. That suggests two steps: one pattern for the fixed frame, and a second, simpler one for the pairs inside the message.

The frame pattern

Named groups make the result self-describing, and re.VERBOSE lets each part carry a label. Note that in verbose mode the literal spaces are written as \s, because plain spaces in the pattern are ignored.

import re log_re = re.compile(r""" (?P<date>\d{4}-\d{2}-\d{2})\s (?P<time>\d{2}:\d{2}:\d{2})\s (?P<level>[A-Z]+)\s \[(?P<source>\w+)\]\s (?P<msg>.*) """, re.X) line = "2026-11-02 09:15:03 ERROR [auth] Login failed for user=ann ip=10.0.0.5" m = log_re.fullmatch(line) print(m.groupdict()) # {'date': '2026-11-02', 'time': '09:15:03', 'level': 'ERROR', 'source': 'auth', 'msg': 'Login failed for user=ann ip=10.0.0.5'}

groupdict() returns exactly the dictionary we wanted. The last group uses .* deliberately: it is the one place where "anything" is the right answer, and because it is at the end and the match is anchored by fullmatch, it cannot swallow the earlier fields.

The key=value pattern

For the message, findall with two groups returns a list of pairs, and dict() turns that list straight into a dictionary:

kv_re = re.compile(r"(\w+)=(\S+)") print(dict(kv_re.findall(m["msg"]))) # {'user': 'ann', 'ip': '10.0.0.5'} print(kv_re.findall("no pairs here")) # []

A message with no pairs simply gives an empty list, and so an empty dictionary. No special case is needed. This is the benefit of using findall rather than search here: "nothing found" is a normal, harmless result.

Never assume every line is good

Real logs contain blank lines, truncated lines and lines from some other program. A parser that raises an error on the first odd line is useless on a big file. The habit is to return None for a line that does not match, and let the caller decide what to do. Here is the complete parser, with the bad lines collected rather than hidden:

def parse_line(line): m = log_re.fullmatch(line) if not m: return None rec = m.groupdict() rec["fields"] = dict(kv_re.findall(rec.pop("msg"))) return rec def parse_log(text): records, bad = [], [] for n, line in enumerate(text.splitlines(), 1): rec = parse_line(line) if rec: records.append(rec) else: bad.append((n, line)) return records, bad logs = """\ 2026-11-02 09:15:03 INFO [web] Request ok path=/home ms=12 2026-11-02 09:15:07 ERROR [auth] Login failed for user=ann ip=10.0.0.5 this line is damaged 2026-11-02 09:16:41 WARN [web] Slow request path=/search ms=870 2026-11-02 09:17:02 ERROR [db] Timeout after ms=3000 2026-11-02 09:18:30 ERROR [auth] Login failed for user=bob ip=10.0.0.9 2026-11-02 09:19:12 INFO [web] Request ok path=/home ms=15 """ records, bad = parse_log(logs) print(len(records), "parsed,", len(bad), "skipped") for n, line in bad: print(" line", n, "skipped:", line) # 6 parsed, 1 skipped # line 3 skipped: this line is damaged

Two details are worth noticing. splitlines() removes the line endings, so the pattern never has to think about \n. And rec.pop("msg") removes the raw message as it is used, so each record holds the structured fields only.

From records to a report

Once each line is a dictionary, the regular expressions are finished. Everything else is ordinary Python, and collections.Counter does most of the counting:

from collections import Counter print("By level:") for lvl, c in Counter(r["level"] for r in records).most_common(): print(f" {lvl:<6}{c}") errors = Counter(r["source"] for r in records if r["level"] == "ERROR") print("Errors by source:", dict(errors)) timed = [r for r in records if "ms" in r["fields"]] slowest = max(timed, key=lambda r: int(r["fields"]["ms"])) print("Slowest:", slowest["time"], slowest["source"], slowest["fields"]["ms"] + " ms") ips = sorted({r["fields"]["ip"] for r in records if "ip" in r["fields"]}) print("IPs:", ips) # By level: # ERROR 3 # INFO 2 # WARN 1 # Errors by source: {'auth': 2, 'db': 1} # Slowest: 09:17:02 db 3000 ms # IPs: ['10.0.0.5', '10.0.0.9']

Values from kv_re are always strings, so numbers such as ms are converted with int() at the point of use. Converting too early, before you know a field is present and numeric, is a common source of errors.

JobToolWhy
Split a line into fixed fieldsOne fullmatch with named groupsThe whole line must fit; fields come back by name
Pull out a variable number of pairsfindall then dict()Zero pairs is a normal, empty result
Skip bad lines safelyReturn None, collect the failuresOne odd line must not stop the run
Count and summariseCounter, max, sortedNo pattern needed once the data is structured

Practical habits

  • Look at real lines first and note which parts are rigid and which are loose.
  • Use two simple patterns (a frame and the details) rather than one that does everything.
  • Use named groups so the code reads like the log format.
  • Keep the bad lines and report how many there were. Silent skipping hides format changes.
  • Parse once into dictionaries, then do all counting and filtering on the structured data.
  • Large files: loop over the open file object line by line rather than reading everything into one string.

Challenges

Exercise 1

Write parse_access(line) for web-server style lines such as 192.168.1.5 - - [02/Nov/2026:09:15:03] "GET /index.html HTTP/1.1" 200 512. Use named groups and return a dictionary with ip, when, method, path, status (as an int) and size (as an int, with - meaning 0), or None if the line does not match. Test it on that line, on 10.0.0.8 - - [02/Nov/2026:09:15:09] "POST /login HTTP/1.1" 302 -, and on not an access line.

๐Ÿ“„ View solution
Exercise 2

Using the parse_log function from this chapter, parse a six-line log with these levels in order: INFO, WARN, INFO, ERROR, WARN, INFO (use any sources and messages in the same line format). Print each level with its count, sorted by count from highest to lowest, with ties in alphabetical order.

๐Ÿ“„ View solution
Exercise 3

Logs must not leak addresses. Given the lines 10:00 user=ann ip=10.0.0.5 action=login, 10:01 user=bob ip=192.168.1.20 action=logout and 10:02 action=ping, use re.sub with a backreference to replace the address after ip= with x.x.x.x while keeping ip= itself. The line with no address must come through unchanged.

๐Ÿ“„ View solution

Quick Reference

  • Frame pattern: re.X with named groups and \s for spaces, matched with fullmatch
  • groupdict(): the match as a dictionary keyed by group name
  • findall + dict: turns key=value pairs into a dictionary; no pairs gives {}
  • Bad lines: return None and collect them with their line numbers
  • splitlines: strips line endings so patterns ignore \n
  • Report: Counter and max on the parsed records; convert numbers with int() when used
Coming next
Regular Expressions in Practice 9 looks at performance and pitfalls: how a pattern can become slow, what causes it, and when not to use a pattern at all.