When a Regex Is the Right Tool

The Course
•
September 18, 2026
•
7 min read

The message part of each line holds key=value pairs: user=ada action=login. Turning that into a dictionary looks like a job for the string methods you already know. For most lines, it is.

String methods, until they aren't Python
def parse_fields(message):
    return dict(pair.split("=", 1) for pair in message.split())

print(parse_fields("user=ada action=login"))
print(parse_fields('order=1043 msg="card declined"'))

The first line works. The second fails, because split() cuts wherever there’s whitespace, and "card declined" has a space inside a value. The pieces msg="card and declined" are no longer pairs.

This is the moment to change tools, and it’s worth being precise about why:

The format…Use
is fields separated by something that never appears inside a fieldsplit, partition, startswith
has quoting, optional parts, or “this or that”a regular expression

The line’s prefix, the date, time and level, fits the first row, and it stays with split() from the previous lesson. Only the message needs a regex. Converting code that already works to a regex doesn’t make it better. It just makes it harder to read.

Describing the pattern

A regular expression describes what the text looks like, not where to cut it. Here, a field is a word, an =, then either a quoted value or a bare one:

Building the pattern Python
import re

message = 'order=1043 msg="card declined" retry=1'

# A key is a word; a value is either "anything but a quote, in quotes" or a word.
pattern = re.compile(r'(\w+)=("[^"]*"|\S+)')
print(pattern.findall(message))

# Named groups say what each part is, and finditer yields one match at a time.
pattern = re.compile(r'(?P<key>\w+)=(?P<value>"[^"]*"|\S+)')
for match in pattern.finditer(message):
    print(match["key"], "->", match["value"].strip('"'))

Just five pieces are doing all of that:

PieceMeans
\w+one or more “word” characters: letters, digits, underscore
"[^"]*"a quote, then any characters that are not a quote, then a quote
\S+one or more non-whitespace characters
A|Btry A, and if it doesn’t match here, try B
(?P<name>…)a group you can read back by name

Write patterns as raw strings, r'…'. A backslash means something to two readers: Python reads the string literal first, and re reads the pattern after. r tells Python to leave every backslash alone for re. Without it, some escapes change meaning before re sees them. "\bword\b" holds two backspace characters, not the word boundaries r"\bword\b" asks for, so it quietly matches nothing. Others, like "\w", survive by luck, but Python warns about them (SyntaxWarning: invalid escape sequence), because the luck may run out.

Once a pattern has more than a couple of parts, re.VERBOSE lets you lay it out and comment it. Whitespace in the pattern is then ignored, so you can use it for layout:

A pattern you can read next month Python
import re

FIELD = re.compile(r"""
    (?P<key>\w+)          # a key: letters, digits, underscore
    =
    (?P<value>
        "[^"]*"           # either a quoted value, which may contain spaces,
      | \S+               # or a bare value, up to the next whitespace
    )
""", re.VERBOSE)

print({m["key"]: m["value"].strip('"') for m in FIELD.finditer('a=1 b="two words"')})
Extraction, not validation

finditer finds fields wherever they are, and skips everything else. That’s deliberate here, because log messages mix fields with free text, like card declined order=1043. But it means this pattern extracts on a best-effort basis. It doesn’t check that a message is well formed. A key that appears twice keeps its last value, and msg="unterminated still yields a msg (the quote alternative fails, so the bare-value one takes over). If a format had to be strictly key=value pairs and nothing else, you’d use fullmatch on the whole message instead. The day’s last lesson shows how.

Bug hunt: the regex that ate a line

A colleague’s first version used ".*" for quoted values. It passed their test. Then a line with two quoted values came in:

Two quoted values Python
import re

FIELD = re.compile(r'(?P<key>\w+)=(?P<value>".*"|\S+)')


def parse_fields(message):
    return {m["key"]: m["value"].strip('"') for m in FIELD.finditer(message)}


print(parse_fields('msg="card declined"'))
print(parse_fields('query="python books" msg="index offline"'))

Reproduce. The second call is the small failing input: two fields, one line.

Inspect. There’s no exception, which makes this one dangerous. What’s wrong only shows in the state: msg has vanished, and query has swallowed everything up to the last quote on the line. When a bug produces wrong output instead of a traceback, inspect the output, and compare it with what you expected.

Narrow. Take the pattern out of the program and try it on the smallest text that shows the problem:

Greedy, lazy, and precise Python
import re

text = '"a" "b"'
print(re.findall(r'"(.*)"', text))      # greedy: as much as possible
print(re.findall(r'"(.*?)"', text))     # lazy: as little as possible
print(re.findall(r'"([^"]*)"', text))   # no quotes allowed inside

.* is greedy: it takes as much as it can while still letting the pattern match. Here that means everything up to the last quote. .*? is its lazy cousin. [^"]* is the most precise choice, because it says what a quoted value actually is: characters that aren’t a quote.

Pin it. A test with two quoted values on one line. It’s the case the original test missed.

Fix. Use the precise version in FIELD, r'(?P<key>\w+)=(?P<value>"[^"]*"|\S+)', and run again.

Test regexes with more than one match

Most regex bugs appear when a pattern that works on one item meets several on the same line. A test with a single quoted value would never have caught this one.

Your turn

Exercise

Parse the fields

Write parse_fields so all five tests pass. One of them has an = inside a quoted value, which is exactly the kind of case that catches out a loose pattern.

import re
import sys
import unittest


def parse_fields(message):
    """Turn 'a=1 b="two words"' into {'a': '1', 'b': 'two words'}."""
    raise NotImplementedError("your code here")


class TestParseFields(unittest.TestCase):
    def test_bare_values(self):
        self.assertEqual(parse_fields("user=ada action=login"),
                         {"user": "ada", "action": "login"})

    def test_quoted_value_with_spaces(self):
        self.assertEqual(parse_fields('msg="card declined"'), {"msg": "card declined"})

    def test_equals_sign_inside_quotes(self):
        self.assertEqual(parse_fields('msg="x=1 failed" retry=2'),
                         {"msg": "x=1 failed", "retry": "2"})

    def test_two_quoted_values(self):
        self.assertEqual(parse_fields('query="python books" msg="index offline"'),
                         {"query": "python books", "msg": "index offline"})

    def test_empty_message(self):
        self.assertEqual(parse_fields(""), {})


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestParseFields)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

Every field is a key, then =, then a value — and a value is either a quoted string, which may contain spaces and = signs, or a run of non-space characters. That “either / or” is regex alternation, and the order of the two alternatives matters. finditer then walks the message one field at a time.

Hint 2: a first step

Get the bare-values test passing first, with a pattern for “a word, =, then non-spaces”: (\w+)=(\S+). Then add the quoted alternative — and try it both ways round against the quoted-value test to see why only one order works.

Show one solution
FIELD = re.compile(r'(?P<key>\w+)=(?P<value>"[^"]*"|\S+)')


def parse_fields(message):
    """Turn 'a=1 b="two words"' into {'a': '1', 'b': 'two words'}."""
    return {m["key"]: m["value"].strip('"') for m in FIELD.finditer(message)}

The = inside the quotes is harmless, because finditer carries on after each match. Once msg="x=1 failed" has matched as a whole, the x=1 inside it is never looked at again.

The order of the alternatives matters, though. Regex alternation tries left to right and takes the first one that matches. Write the bare value first, (\S+|"[^"]*"), and \S+ happily matches "card on its own, so msg="card declined" silently becomes {'msg': 'card'}. Put the most specific alternative first.

What you can now do

  • Decide between string methods and a regex by the shape of the format, not by habit.
  • Write a pattern with character classes, alternation and named groups, and lay it out with re.VERBOSE.
  • Recognise greedy matching, and use a negated class like [^"]* to say exactly what a value may contain.

Next: the log files get big, and reading them all into memory stops being an option.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.