Skip to main content

Sequence Patterns: Destructuring Lists and Tuples

Sequence patterns match lists and tuples and destructure their elements in a single operation. Syntax: case [pattern1, pattern2, ...] matches sequences of specific length and extracts elements into variables. Sequence patterns support variadic matching using *rest to capture zero or more elements, enabling head-tail destructuring like functional programming languages. This is essential for parsing command-line arguments, processing CSV rows, matching log entries, and handling any data that arrives as a list or tuple. Instead of indexing into a list and checking lengths manually, a sequence pattern validates the structure and extracts elements atomically.

I used sequence patterns to build a command-line parser that processes arguments in a single pattern match instead of a 30-line state machine. The result was 60% shorter, easier to extend, and self-documenting. This article covers fixed-length patterns, variable-length patterns with *rest, extracting heads and tails, type-checked sequences, and combining sequence patterns with guards.

Basic Sequence Pattern Syntax

A sequence pattern is a list or tuple literal in a case clause. Python matches the length and elements, destructuring values into bound variables. The pattern must match the sequence length exactly unless you use a *rest pattern.

def process_coordinates(point: list) -> str:
"""Match a 2D coordinate point."""
match point:
case [0, 0]:
return "Origin"
case [x, 0]:
return f"On x-axis at {x}"
case [0, y]:
return f"On y-axis at {y}"
case [x, y]:
return f"Point at ({x}, {y})"
case _:
return "Invalid point"

# Test with different points
print(process_coordinates([0, 0])) # Output: Origin
print(process_coordinates([5, 0])) # Output: On x-axis at 5
print(process_coordinates([0, 3])) # Output: On y-axis at 3
print(process_coordinates([2, 4])) # Output: Point at (2, 4)
print(process_coordinates([1])) # Output: Invalid point (wrong length)

The pattern [x, 0] matches lists of length 2 where the second element is 0 and captures the first element as x. If the list has a different length or the second element isn't 0, the pattern doesn't match. Order matters—more specific patterns should come first.

Head-Tail Destructuring with Rest Patterns

The *rest pattern captures zero or more elements as a list. Use [first, *rest] to destructure a sequence into head and tail, a classic functional programming pattern.

def analyze_numbers(nums: list) -> str:
"""Analyze a list of numbers."""
match nums:
case []:
return "Empty list"
case [single]:
return f"Single number: {single}"
case [first, second]:
return f"Two numbers: {first} and {second}"
case [first, *middle, last]:
avg = sum(middle) / len(middle) if middle else 0
return f"List: {first}...[{len(middle)} middle]...{last} (middle avg: {avg:.1f})"
case _:
return "Other sequence"

# Test with lists of various lengths
print(analyze_numbers([])) # Output: Empty list
print(analyze_numbers([42])) # Output: Single number: 42
print(analyze_numbers([1, 2])) # Output: Two numbers: 1 and 2
print(analyze_numbers([1, 2, 3, 4, 5])) # Output: List: 1...[3 middle]...5 (middle avg: 3.0)

The pattern [first, *middle, last] matches any sequence with at least 2 elements, binding the first to first, the last to last, and all middle elements (as a list) to middle. This is powerful for processing variable-length sequences like command arguments or log lines.

Type-Checked Sequence Patterns

Combine sequence patterns with type patterns to validate both structure and element types. This ensures data integrity when processing external data.

def process_mixed_list(items: list) -> str:
"""Process a list, validating types."""
match items:
case [int(first), int(second)]:
return f"Two integers: {first} + {second} = {first + second}"
case [str(name), int(age)]:
return f"Person: {name}, age {age}"
case [int(x), str(op), int(y)] if op in ["+", "-", "*"]:
result = eval(f"{x}{op}{y}")
return f"Operation: {x} {op} {y} = {result}"
case [int(x), str(op), int(y)]:
return f"Unknown operator: {op}"
case [*numbers] if all(isinstance(n, int) for n in numbers):
return f"List of {len(numbers)} integers: {numbers}"
case _:
return "Invalid list format"

# Test with different list structures
print(process_mixed_list([10, 20])) # Output: Two integers: 10 + 20 = 30
print(process_mixed_list(["Alice", 30])) # Output: Person: Alice, age 30
print(process_mixed_list([5, "+", 3])) # Output: Operation: 5 + 3 = 8
print(process_mixed_list([1, 2, 3, 4])) # Output: List of 4 integers: [1, 2, 3, 4]

The pattern [int(first), int(second)] matches lists of length 2 with integer elements. Type patterns validate element types atomically. The pattern [*numbers] if all(isinstance(n, int) for n in numbers) uses a guard to validate all elements are integers.

Nested Sequence Patterns for Complex Data

Sequence patterns nest arbitrarily deep, extracting values from nested lists and tuples. This is ideal for parsing tabular data, matrix-like structures, and hierarchical information.

def process_matrix_row(row: list) -> str:
"""Process a matrix row with nested structure."""
match row:
case [[x, y], [z, w]]:
# A 2x2 matrix
det = x * w - y * z
return f"Matrix determinant: {det}"
case [header, *data_rows]:
total = sum(sum(row) for row in data_rows if isinstance(row, list))
return f"Table with header {header}: {len(data_rows)} rows, total: {total}"
case [first, second, *rest]:
return f"First: {first}, Second: {second}, Rest: {rest}"
case _:
return "Unrecognized structure"

# Test with nested lists
print(process_matrix_row([[1, 2], [3, 4]]))
# Output: Matrix determinant: -2

print(process_matrix_row(["Name", [1, 2], [3, 4], [5, 6]]))
# Output: Table with header Name: 3 rows, total: 21

The pattern [[x, y], [z, w]] matches a 2x2 nested list, extracting all four elements. Nested patterns are evaluated recursively, validating structure at each level.

Command-Line Argument Parsing with Sequences

Sequence patterns excel at parsing command-line arguments where the structure is known but the count may vary.

def parse_command(args: list) -> str:
"""Parse a command with arguments."""
match args:
case ["help"]:
return "Help: usage is 'command [args]'"
case ["config", "get", key]:
return f"Get config: {key}"
case ["config", "set", key, value]:
return f"Set config: {key} = {value}"
case ["config", "set", key, *values]:
return f"Set config: {key} = {' '.join(values)}"
case ["list", *items]:
return f"List with {len(items)} items: {items}"
case ["echo", *msg]:
return " ".join(msg)
case [command]:
return f"Unknown command: {command}"
case _:
return "Invalid input"

# Test command parsing
print(parse_command(["help"])) # Output: Help: usage is 'command [args]'
print(parse_command(["config", "get", "timeout"])) # Output: Get config: timeout
print(parse_command(["config", "set", "port", "8080"])) # Output: Set config: port = 8080
print(parse_command(["config", "set", "tags", "a", "b", "c"]))
# Output: Set config: tags = a b c
print(parse_command(["echo", "hello", "world"])) # Output: hello world

The pattern ["config", "set", key, *values] matches any command starting with ["config", "set", ...] and captures the remaining arguments as values. This is far cleaner than manual argument parsing.

Comparison Table: Sequence Patterns vs. Manual Indexing

ApproachSafetyReadabilityValidates Structure
Manual indexing: items[0], items[1]Low; IndexError if shortLow; hard to document intentNo; must check len() separately
if with length check: if len(items) == 2: x, y = items[0], items[1]Medium; length checkedMedium; verboseYes; checked manually
Sequence pattern: case [x, y]:High; pattern fails gracefullyHigh; intent is clearYes; validated automatically
# Manual approach: error-prone
def get_first_two_manual(items):
if len(items) >= 2:
return items[0], items[1]
return None, None

# Sequence pattern approach: safe
def get_first_two_pattern(items):
match items:
case [first, second, *_]:
return first, second
case _:
return None, None

# Both work, but pattern version validates structure implicitly
test_list = [1, 2, 3, 4]
print(get_first_two_manual(test_list)) # Output: (1, 2)
print(get_first_two_pattern(test_list)) # Output: (1, 2)

Key Takeaways

  • Sequence patterns match lists and tuples and destructure elements: case [x, y, z]:.
  • The *rest pattern captures zero or more elements as a list, enabling head-tail destructuring.
  • Patterns must match sequence length exactly unless *rest is used.
  • Type patterns in sequences validate element types: case [int(x), str(y)]:.
  • Nested sequence patterns extract values from nested lists in a single expression.
  • Sequence patterns with guards enable complex filtering logic.
  • Patterns validate structure and extract values atomically, eliminating IndexError and manual length checks.

Frequently Asked Questions

Can I have multiple *rest patterns in one case?

No, only one *rest pattern per case. It captures "everything else" in the sequence. Multiple would be ambiguous.

What if the list has fewer elements than the pattern expects?

The pattern doesn't match, and Python tries the next case. For example, case [x, y]: doesn't match a one-element list. To handle variable lengths, use *rest: case [x, *rest]:.

Can I use *rest at the beginning instead of the end?

Yes: case [*head, last]: captures all but the last element as head. You can use *rest anywhere in a sequence pattern: start, middle, or end.

How do I match a tuple instead of a list?

Use the same syntax: case (x, y):. Python treats () and [] similarly in patterns. A pattern [x, y] matches both lists and tuples.

Can I nest *rest patterns?

No, *rest can appear only once in a sequence pattern. To extract nested elements, use nested sequence patterns: case [[x, *rest], y]:.

Further Reading