The same line, three ways. Only the third one gives the message back exactly as it was written.
line = '2026-09-10 14:03:22 ERROR Disk full on /dev/sda1' print(line.split()) # ['2026-09-10', '14:03:22', 'ERROR', 'Disk', 'full', 'on', '/dev/sda1'] # -> the message is now seven pieces, and the double space is gone date, time, level, message = line.split() # ValueError: too many values print(line.split(None, 3)) # ['2026-09-10', '14:03:22', 'ERROR', 'Disk full on /dev/sda1']
- maxsplit stops splitting, it does not stop reading
split(None, 3) makes at most three cuts and hands back everything after the third one as a single piece — untouched, including the double space. That is exactly the shape of a log line: a known number of fields, then a tail that is not fields at all. The first argument is the separator, and None means 'any run of whitespace', which is also what split() with no arguments does.
- Unpacking is a length assertion in disguise
a, b, c, d = line.split() only works when there are exactly four pieces, so it fails on every message that is not one word — and the error says 'too many values to unpack', which points at the assignment rather than at the real problem. With maxsplit the count is fixed by construction, and unpacking becomes safe to use.
- Rejoining is not the same as never splitting
' '.join(parts[3:]) looks like it undoes the damage, and it silently rewrites 'full on /dev/sda1' with single spaces. A log message may hold aligned columns, an indented traceback or a quoted string where the spacing is the content. If you never split it, you cannot get it wrong.
Strip the line, then split it — but not all the way. There are three fields and then a message that must not be cut up at all.
line.strip().split(None, 3) gives at most four pieces: date, time, level, and the whole message. Check how many pieces came back before you index into them: three means an empty message, fewer than three is not a log line.
Keep the valid levels in a set at module level so the membership test is one line and the list of levels is easy to find. parts[3] if len(parts) == 4 else '' handles the missing message without a second split.
LEVELS = {'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'} def parse_log_line(line): parts = line.strip().split(None, ____) if len(parts) < ____: raise ValueError(f’not a log line: {line!r}') date, time, level = parts[0], parts[1], parts[2] if level not in ____: raise ____(f’unknown level {level!r}') return { 'timestamp': f'{date} {time}', 'level': level, 'message': parts[3] if len(parts) == 4 else '____', }
LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
def parse_log_line(line):
parts = line.strip().split(None, 3)
if len(parts) < 3:
raise ValueError(f"not a log line: {line!r}")
date, time, level = parts[0], parts[1], parts[2]
if level not in LEVELS:
raise ValueError(f"unknown level {level!r}")
return {
"timestamp": f"{date} {time}",
"level": level,
"message": parts[3] if len(parts) == 4 else "",
}
Asking for at most three cuts guarantees at most four pieces, which is why indexing parts[0] through parts[3] is safe once the length is checked. That relationship — maxsplit n gives at most n + 1 pieces — is worth holding onto, because it turns 'I hope this line is well formed' into an arithmetic fact.
level in LEVELS is one lookup regardless of how many levels there are, and adding a sixth is a one-word edit in an obvious place. The version people write first — level == 'DEBUG' or level == 'INFO' or ... — grows sideways and eventually gets an `or level == 'WARN'` bolted on by somebody in a hurry.
Every function downstream of this one gets to assume level is one of five strings, because this function is the only place a line can enter the system. Accepting an unknown level and hoping means every later consumer needs its own defensive check, and they will not all agree about what to do.
The moment the format has quoting, escaping or optional fields, hand-splitting stops being enough — a message containing a quoted string with a tab in it will defeat this. Structured logs (JSON per line) are the right answer for anything you control: json.loads is one call and the ambiguity disappears entirely. For formats you do not control, a named regular expression with groups documents the shape better than index arithmetic, and libraries already exist for the common ones — Apache and nginx access logs in particular. Write this when the format is simple, stable, and yours.
- Parse the timestamp into a real datetime with datetime.strptime, and decide what should happen to a line whose date is nonsense.
- Add support for an optional logger name in brackets after the level, and see how quickly the field counting stops being enough.
- Write the inverse — format_log_line(record) — and check that parsing its output gives the record back for every case here.