Python Tuples: The Complete Guide to Immutable Sequences

Of all the built-in data types in Python, tuples are the most quietly powerful. They sit beside lists in nearly every introductory tutorial, yet many developers treat them as nothing more than "lists you cannot change." That description misses the point entirely. Tuples are not restricted lists — they are a fundamentally different data structure with a different purpose. They represent fixed collections of values, serve as immutable records, act as dictionary keys, enable multiple return values from functions, and underpin some of the most important patterns in the language. Python itself uses tuples internally far more than most developers realize: every time you write a multi-value return, unpack variables in a for loop, or pass *args to a function, tuples are doing the work behind the scenes. This guide covers tuples from the ground up — creation, immutability, indexing, slicing, packing, unpacking, named tuples, performance internals, type hints, and the real-world patterns where tuples are the right choice. Every concept is backed by code you can run today.

"Tuples are immutable, and usually contain a heterogeneous sequence of elements that are accessed via unpacking or indexing. Lists are mutable, and their elements are usually homogeneous and are accessed by iterating over the list." — Guido van Rossum, The Python Tutorial

That distinction from Python's creator is the single most important sentence to understand about tuples. Lists are collections of similar things — a list of filenames, a list of scores, a list of users. Tuples are records of related but different things — a coordinate pair (x, y), a database row (id, name, email), an RGB color (255, 128, 0). The immutability of tuples is not a limitation; it is a guarantee. It tells every developer who reads your code: this data is not meant to change. That clarity of intent is why tuples exist as a separate type alongside lists.

What Is a Tuple?

A tuple is an ordered, immutable sequence of elements. "Ordered" means every element has a fixed position, accessible by index. "Immutable" means once the tuple is created, you cannot add, remove, or replace any of its elements. "Sequence" means tuples support the same family of operations that strings and lists support: indexing, slicing, iteration, membership testing with in, and length with len().

Python tuples have existed since Python's earliest public releases in the early 1990s. Guido van Rossum drew inspiration from the ABC language, which featured immutable compound types alongside mutable lists. In a 2011 presentation titled 21 Years of Python, van Rossum listed tuples and immutable data types among the ABC features he valued most when designing Python. The word "tuple" itself comes from mathematics, where it refers to a finite ordered sequence of elements: a single value is a singleton, a pair is a 2-tuple, three values form a 3-tuple, and the general term for n values is an n-tuple.

# A tuple is an ordered, immutable sequence
coordinates = (40.7128, -74.0060)   # A 2-tuple (latitude, longitude)
rgb_color = (255, 128, 0)           # A 3-tuple (red, green, blue)
server_info = ("db-prod-01", "10.0.1.5", 5432, True)  # A 4-tuple

print(type(coordinates))  # <class 'tuple'>
print(len(server_info))   # 4

Creating Tuples: Every Way That Works

There are several ways to create a tuple in Python, and understanding the subtle differences between them will save you from common bugs — especially the infamous single-element tuple trap.

Parentheses (The Standard Way)

The most recognizable way to create a tuple is with parentheses. However, it is the commas that actually make a tuple, not the parentheses. The parentheses are optional in most contexts and serve only to improve readability or to resolve ambiguity.

# Standard tuple creation with parentheses
colors = ("red", "green", "blue")
print(colors)  # ('red', 'green', 'blue')

# Without parentheses: the commas create the tuple
dimensions = 1920, 1080
print(type(dimensions))  # <class 'tuple'>

# The empty tuple requires parentheses (or the tuple() constructor)
empty = ()
print(type(empty))  # <class 'tuple'>
print(len(empty))   # 0

The Single-Element Trap

This is one of the most common mistakes in Python. Writing ("hello") does not create a tuple — the parentheses are treated as grouping operators, just as they would be in a math expression like (2 + 3). To create a single-element tuple, you must include a trailing comma.

# WRONG: this is a string, not a tuple
not_a_tuple = ("hello")
print(type(not_a_tuple))  # <class 'str'>

# CORRECT: trailing comma creates a single-element tuple
is_a_tuple = ("hello",)
print(type(is_a_tuple))   # <class 'tuple'>

# Also works without parentheses
also_a_tuple = "hello",
print(type(also_a_tuple)) # <class 'tuple'>
Common Pitfall

The trailing comma in ("hello",) is not optional for single-element tuples. Forgetting it is a frequent source of bugs, especially when building tuples dynamically or passing them as function arguments. If your code expects a tuple and receives a bare string instead, iteration will loop over individual characters rather than the single string element.

The tuple() Constructor

The built-in tuple() function converts any iterable into a tuple. This is useful when you need to "freeze" a list into an immutable form or when constructing tuples from generators, ranges, or other iterables.

# Convert a list to a tuple
ports_list = [80, 443, 8080, 8443]
ports_tuple = tuple(ports_list)
print(ports_tuple)  # (80, 443, 8080, 8443)

# From a range
first_ten = tuple(range(10))
print(first_ten)  # (0, 1, 2, 3, 4, 5, 6, 7, 8, 9)

# From a string (each character becomes an element)
chars = tuple("Python")
print(chars)  # ('P', 'y', 't', 'h', 'o', 'n')

# From a generator expression
squares = tuple(x ** 2 for x in range(6))
print(squares)  # (0, 1, 4, 9, 16, 25)

# Empty tuple via constructor
also_empty = tuple()
print(also_empty)  # ()

Immutability: What It Really Means

"One is performance: knowing that a string is immutable means we can allocate space for it at creation time, and the storage requirements are fixed and unchanging. This is also one of the reasons for the distinction between tuples and lists." — Python Design and History FAQ, docs.python.org

Immutability means that the tuple's structure — the number of elements and which objects they reference — is fixed at creation time. You cannot assign to an index, append, remove, or sort a tuple in place. Any attempt to modify a tuple raises a TypeError.

server = ("web-01", "10.0.0.1", 443)

# All of these raise TypeError:
# server[0] = "web-02"       # TypeError: 'tuple' object does not support item assignment
# server.append("https")     # AttributeError: 'tuple' object has no attribute 'append'
# del server[1]              # TypeError: 'tuple' object doesn't support item deletion

The Mutable Contents Gotcha

Here is a subtlety that catches even experienced developers: a tuple's immutability applies to the references it holds, not to the objects those references point to. If a tuple contains a mutable object like a list, the list itself can still be modified. The tuple still holds the same reference to the same list object — it just so happens that the list's contents have changed.

# A tuple containing a mutable list
config = ("production", [80, 443])

# The list inside the tuple can still be modified
config[1].append(8080)
print(config)  # ('production', [80, 443, 8080])

# But you CANNOT replace the list with a new object
# config[1] = [80, 443, 8080]  # TypeError

# The id of the list object hasn't changed
print(id(config[1]))  # Same address before and after append
Note

This distinction matters for hashability. A tuple can only be used as a dictionary key or set member if all of its elements are themselves hashable (and therefore immutable). A tuple containing a list is not hashable and will raise a TypeError if you try to use it as a key. We cover this in detail in the dictionary keys section below.

This behavior is consistent with how Python's object model works. Variables and collection elements in Python are references (pointers) to objects, not the objects themselves. When the Python documentation says tuples are immutable, it means the array of references inside the tuple cannot be reassigned. The objects at the other end of those references follow their own mutability rules.

Indexing, Slicing, and Iterating

Tuples support the same indexing and slicing syntax as lists and strings. They are zero-indexed, support negative indices for counting from the end, and return new tuples when sliced.

log_entry = ("2026-02-14", "ERROR", "db-prod-01", "Connection timeout", 503)

# Positive indexing (starts at 0)
print(log_entry[0])   # '2026-02-14'
print(log_entry[3])   # 'Connection timeout'

# Negative indexing (starts at -1 from the end)
print(log_entry[-1])  # 503
print(log_entry[-2])  # 'Connection timeout'

# Slicing returns a NEW tuple
metadata = log_entry[0:3]
print(metadata)       # ('2026-02-14', 'ERROR', 'db-prod-01')
print(type(metadata)) # <class 'tuple'>

# Slice with step
every_other = log_entry[::2]
print(every_other)    # ('2026-02-14', 'db-prod-01', 503)

# Reverse a tuple
reversed_entry = log_entry[::-1]
print(reversed_entry) # (503, 'Connection timeout', 'db-prod-01', 'ERROR', '2026-02-14')

Iteration works exactly as you would expect. You can loop over tuple elements directly with a for loop, or use enumerate() to get both index and value.

protocols = ("HTTP", "HTTPS", "FTP", "SSH", "DNS")

# Direct iteration
for protocol in protocols:
    print(f"Supported: {protocol}")

# Enumerate for index + value
for i, protocol in enumerate(protocols):
    print(f"  [{i}] {protocol}")

Packing and Unpacking

Tuple packing and unpacking are two of Python's most elegant features. Packing is the act of combining multiple values into a single tuple. Unpacking is the reverse — extracting tuple elements into individual variables in a single assignment.

Packing

# Tuple packing: commas create the tuple
host = "10.0.0.1"
port = 5432
db_name = "analytics"

connection_info = host, port, db_name  # Packing into a tuple
print(connection_info)  # ('10.0.0.1', 5432, 'analytics')

Unpacking

# Tuple unpacking: assign each element to a variable
address, port_num, database = connection_info
print(address)   # 10.0.0.1
print(port_num)  # 5432
print(database)  # analytics

# Unpacking is how Python's "swap trick" works
a = 10
b = 20
a, b = b, a    # The right side packs into (20, 10), then unpacks
print(a, b)    # 20 10

Extended Unpacking with the Star Operator

PEP 3132 – Extended Iterable Unpacking, authored by Georg Brandl and accepted by Guido van Rossum for Python 3.0, introduced the * operator in assignment targets. This allows you to capture a variable number of elements during unpacking. The starred variable always receives a list (even if it captures zero elements), and there can be at most one starred expression per assignment.

scores = (98, 87, 92, 78, 95, 88, 91)

# Capture the first, last, and everything in between
first, *middle, last = scores
print(first)    # 98
print(middle)   # [87, 92, 78, 95, 88]  (always a list)
print(last)     # 91

# Capture the head and the rest
head, *tail = scores
print(head)     # 98
print(tail)     # [87, 92, 78, 95, 88, 91]

# Discard the middle with _
first, *_, last = scores
print(first, last)  # 98 91
Pro Tip

The convention of using _ as a throwaway variable name is just that — a convention. Python assigns the value to _ like any other variable. But it signals to anyone reading your code that the value is intentionally being discarded.

Unpacking in For Loops

Tuple unpacking is especially powerful when iterating over sequences of tuples, such as rows from a database query or the output of enumerate() and zip().

# Database rows as tuples
users = [
    (1, "kandi", "kandi@example.com", True),
    (2, "alex", "alex@example.com", False),
    (3, "jordan", "jordan@example.com", True),
]

for user_id, username, email, is_active in users:
    status = "active" if is_active else "inactive"
    print(f"  {username} ({email}) - {status}")

# zip() produces tuples
hosts = ("web-01", "web-02", "web-03")
ips = ("10.0.0.1", "10.0.0.2", "10.0.0.3")

for hostname, ip_addr in zip(hosts, ips):
    print(f"  {hostname} -> {ip_addr}")

Tuples as Dictionary Keys and Set Members

Because tuples are immutable, they are hashable — provided all of their elements are also hashable. This makes tuples eligible to serve as dictionary keys and set members, which is something lists can never do. This property is one of the most important practical reasons to choose a tuple over a list.

The Python Design and History FAQ on docs.python.org explains this directly: if you want a dictionary indexed with a list, convert the list to a tuple first with tuple(L), because tuples are immutable and can serve as dictionary keys.

# Tuples as dictionary keys: mapping (x, y) coordinates to values
grid = {}
grid[(0, 0)] = "origin"
grid[(1, 0)] = "east"
grid[(0, 1)] = "north"
grid[(-1, 0)] = "west"

print(grid[(0, 0)])  # 'origin'

# Real-world use: caching function results by argument tuple
def expensive_computation(x, y, z):
    """Simulates a slow calculation with manual caching."""
    cache_key = (x, y, z)
    if cache_key in _cache:
        print(f"  Cache hit for {cache_key}")
        return _cache[cache_key]
    result = x ** 2 + y ** 2 + z ** 2  # Imagine this is expensive
    _cache[cache_key] = result
    return result

_cache = {}
print(expensive_computation(3, 4, 5))   # Computes: 50
print(expensive_computation(3, 4, 5))   # Cache hit for (3, 4, 5): 50
# Tuples in sets: tracking unique coordinate pairs
visited_coordinates = set()
path = [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]  # Loop back to start

for point in path:
    visited_coordinates.add(point)  # Works because tuples are hashable

print(f"Unique locations visited: {len(visited_coordinates)}")  # 4

# A list CANNOT be a set member or dict key
# visited_coordinates.add([2, 3])  # TypeError: unhashable type: 'list'
Watch Out

A tuple that contains a mutable element (like a list) is not hashable. Attempting to use (1, [2, 3]) as a dictionary key or set member will raise TypeError: unhashable type: 'list'. For a tuple to be hashable, every element it contains must also be hashable, all the way down.

Tuple Methods and Built-In Operations

Tuples have exactly two methods: count() and index(). That is not an accident. As the Python Design and History FAQ explains, immutable types intentionally restrict modification operations to maintain their immutability guarantee. Lists expose sixteen methods; tuples expose two. The difference reflects a deliberate design decision, not a limitation.

status_codes = (200, 301, 404, 200, 500, 200, 404)

# count(): how many times does a value appear?
print(status_codes.count(200))  # 3
print(status_codes.count(500))  # 1
print(status_codes.count(418))  # 0

# index(): where does the first occurrence appear?
print(status_codes.index(404))  # 2  (first occurrence)
print(status_codes.index(500))  # 4

# index() with start and stop parameters
print(status_codes.index(200, 1))     # 3  (first 200 after index 1)
print(status_codes.index(404, 3, 7))  # 5  (first 404 between index 3 and 7)

# index() raises ValueError if the element is not found
# status_codes.index(418)  # ValueError: tuple.index(x): x not in tuple

While tuples have only two methods of their own, they work seamlessly with Python's built-in functions and operators.

temps = (72.1, 68.5, 75.3, 69.8, 73.2, 67.0, 74.6)

# Built-in functions that work with tuples
print(len(temps))    # 7
print(min(temps))    # 67.0
print(max(temps))    # 75.3
print(sum(temps))    # 500.5
print(sorted(temps)) # [67.0, 68.5, 69.8, 72.1, 73.2, 74.6, 75.3] (returns a list)

# Membership testing with 'in'
print(72.1 in temps)   # True
print(80.0 in temps)   # False

# Concatenation creates a NEW tuple
first_half = (1, 2, 3)
second_half = (4, 5, 6)
combined = first_half + second_half
print(combined)  # (1, 2, 3, 4, 5, 6)

# Repetition also creates a NEW tuple
repeated = ("ping",) * 3
print(repeated)  # ('ping', 'ping', 'ping')

# Comparison is lexicographic (element by element)
print((1, 2, 3) < (1, 2, 4))   # True
print((1, 2, 3) < (1, 3, 0))   # True  (second element decides)
print((1, 2) < (1, 2, 0))      # True  (shorter tuple is "less than")

Named Tuples: Adding Meaning to Position

Regular tuples rely on positional access: server[0] is the hostname, server[1] is the IP, server[2] is the port. This works, but it forces you and every other developer on your team to remember what each index means. Named tuples solve this by giving each position a descriptive field name while retaining all the benefits of a regular tuple: immutability, hashability, indexing, and memory efficiency.

Python provides two ways to create named tuples: collections.namedtuple() (the original, available since Python 2.6) and typing.NamedTuple (the modern, type-annotated version, introduced via PEP 484 – Type Hints and refined in Python 3.6 with PEP 526 – Syntax for Variable Annotations). Named tuple instances have no per-instance dictionary, so they require no more memory than regular tuples.

collections.namedtuple (The Classic Way)

from collections import namedtuple

# Define a named tuple class
Server = namedtuple("Server", ["hostname", "ip", "port", "is_active"])

# Create instances
db = Server("db-prod-01", "10.0.1.5", 5432, True)
web = Server(hostname="web-01", ip="10.0.0.1", port=443, is_active=True)

# Access by name (preferred) or by index
print(db.hostname)    # 'db-prod-01'
print(db.port)        # 5432
print(db[0])          # 'db-prod-01'  (index still works)

# Named tuples are still tuples
print(isinstance(db, tuple))  # True

# They have a useful repr
print(db)  # Server(hostname='db-prod-01', ip='10.0.1.5', port=5432, is_active=True)

# Unpack them just like regular tuples
hostname, ip, port, active = db
print(f"{hostname} at {ip}:{port}")  # db-prod-01 at 10.0.1.5:5432

typing.NamedTuple (The Modern Way)

The class-based syntax with typing.NamedTuple is the preferred approach in modern Python. It supports type annotations, default values, docstrings, and methods — making named tuples feel like lightweight, immutable data classes.

from typing import NamedTuple

class NetworkDevice(NamedTuple):
    """An immutable record for a network device."""
    hostname: str
    ip: str
    port: int
    device_type: str = "unknown"  # Default value (Python 3.6.1+)

# Create instances
switch = NetworkDevice("sw-core-01", "10.0.0.2", 22, "switch")
firewall = NetworkDevice("fw-edge-01", "10.0.0.1", 443)

print(firewall.device_type)  # 'unknown' (default used)
print(switch)                # NetworkDevice(hostname='sw-core-01', ...)

# Convert to dict
print(switch._asdict())
# {'hostname': 'sw-core-01', 'ip': '10.0.0.2', 'port': 22, 'device_type': 'switch'}

# Create a modified copy (since named tuples are immutable)
updated = switch._replace(ip="10.0.0.10")
print(updated.ip)  # '10.0.0.10'
print(switch.ip)   # '10.0.0.2' (original unchanged)
"Readability counts." — Tim Peters, The Zen of Python (PEP 20)

Named tuples are a direct embodiment of that principle. Replacing server[2] with server.port makes code self-documenting. When you read device.hostname six months from now, you will not need to check the index mapping.

Type Hints for Tuples

PEP 484 – Type Hints, authored by Guido van Rossum, Jukka Lehtosalo, and Lukasz Langa, introduced formal type annotations for Python. Tuples received special treatment in the type system because, unlike lists, tuples often have a fixed number of elements where each position has a specific type. This means the type system distinguishes between a tuple used as a fixed-length record and a tuple used as a variable-length homogeneous collection.

# Fixed-length tuple: each position has a specific type
# Since Python 3.9+, you can use the built-in tuple directly
coordinate: tuple[float, float] = (40.7128, -74.0060)
user_record: tuple[int, str, bool] = (1, "kandi", True)

# Variable-length homogeneous tuple: use the ... (ellipsis) syntax
status_codes: tuple[int, ...] = (200, 301, 404, 500)
hostnames: tuple[str, ...] = ("web-01", "web-02", "web-03")

# Empty tuple
nothing: tuple[()] = ()

# In function signatures
def get_server_info(name: str) -> tuple[str, int, bool]:
    """Returns (ip_address, port, is_healthy)."""
    return ("10.0.0.1", 443, True)

# Unpack the typed return value
ip, port, healthy = get_server_info("web-01")

For Python 3.8 and earlier, use Tuple from the typing module (with a capital T). From Python 3.9 onward, the built-in tuple works directly in annotations thanks to PEP 585 – Type Hinting Generics In Standard Collections. And PEP 646 – Variadic Generics, accepted for Python 3.11, introduced TypeVarTuple for advanced generic programming with tuples of arbitrary length and varying types, enabling libraries like NumPy and TensorFlow to express array shapes in the type system.

Performance: Why Tuples Are Faster Than Lists

Tuples are not just semantically different from lists — they are measurably faster to create, iterate, and access. The performance advantages come from three properties of immutability that CPython exploits.

1. Smaller Memory Footprint

Lists must allocate extra space to accommodate future growth. When you create a list, CPython over-allocates memory so that subsequent append() calls do not need to resize the underlying array on every call. Tuples, being immutable, allocate exactly the memory they need and nothing more.

import sys

my_list = [1, 2, 3, 4, 5]
my_tuple = (1, 2, 3, 4, 5)

print(f"List size:  {sys.getsizeof(my_list)} bytes")   # 96 bytes (CPython 3.12)
print(f"Tuple size: {sys.getsizeof(my_tuple)} bytes")  # 80 bytes (CPython 3.12)

2. Faster Creation via the Free List

CPython maintains a "free list" for tuples — an internal cache of recently destroyed tuple objects that can be reused instead of allocating new memory. When a tuple with fewer than 20 elements is garbage collected, CPython does not immediately free the memory. Instead, it stores the tuple shell on a size-indexed free list (one list per size from 0 to 19). The next time a tuple of that size is needed, CPython grabs one from the free list instead of calling the system allocator. Lists have a much more limited free list that only covers empty list objects.

import timeit

# Tuple creation is significantly faster
list_time = timeit.timeit("list((1, 2, 3, 4, 5))", number=1_000_000)
tuple_time = timeit.timeit("(1, 2, 3, 4, 5)", number=1_000_000)

print(f"List creation:  {list_time:.3f}s")
print(f"Tuple creation: {tuple_time:.3f}s")
# Typical result: tuples are 5-10x faster to create

3. Constant Folding at Compile Time

When a tuple contains only constant literals (integers, strings, floats, booleans, or nested constant tuples), the Python compiler can build the entire tuple at compile time and store it directly in the bytecode as a constant. Lists, being mutable, must always be constructed at runtime.

import dis

# This function's tuple is compiled as a single constant
def get_tuple():
    return (1, 2, 3)

# This function's list must be built at runtime
def get_list():
    return [1, 2, 3]

print("--- Tuple bytecode ---")
dis.dis(get_tuple)
# LOAD_CONST  (1, 2, 3)   <-- single instruction, pre-built
# RETURN_VALUE

print("\n--- List bytecode ---")
dis.dis(get_list)
# BUILD_LIST  3            <-- must construct at runtime
# RETURN_VALUE
Note

CPython also treats the empty tuple as a singleton. Every () and every tuple() call returns the exact same object in memory. You can verify this with () is (), which evaluates to True. This optimization is safe precisely because tuples are immutable — sharing one empty tuple across the entire program cannot cause side effects.

Real-World Patterns

Theory is useless without practice. Here are the patterns where tuples are the natural, correct choice.

Pattern 1: Multiple Return Values

This is the most common use of tuples in everyday Python. When a function needs to return more than one value, it packs them into a tuple. The caller unpacks them with destructuring assignment.

import os

def analyze_log_file(filepath):
    """Analyze a log file and return summary statistics."""
    total_lines = 0
    error_count = 0
    warning_count = 0

    with open(filepath) as f:
        for line in f:
            total_lines += 1
            if "ERROR" in line:
                error_count += 1
            elif "WARNING" in line:
                warning_count += 1

    error_rate = error_count / total_lines if total_lines > 0 else 0.0
    return total_lines, error_count, warning_count, error_rate

# Unpack the returned tuple
lines, errors, warnings, rate = analyze_log_file("/var/log/app.log")
print(f"Processed {lines} lines: {errors} errors, {warnings} warnings ({rate:.1%} error rate)")

Pattern 2: Immutable Configuration

# Constants that should never change at runtime
ALLOWED_HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD")
SUPPORTED_IMAGE_FORMATS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
DEFAULT_PORTS = {"http": 80, "https": 443, "ssh": 22, "postgresql": 5432}

def validate_request(method, content_type):
    if method.upper() not in ALLOWED_HTTP_METHODS:
        raise ValueError(f"Unsupported HTTP method: {method}")
    return True

# Using tuples here signals intent: these collections are fixed
# If someone tries to .append() to them, they get an immediate TypeError

Pattern 3: Database Rows and CSV Records

from typing import NamedTuple
from datetime import datetime

class SecurityEvent(NamedTuple):
    """A security event from the SIEM."""
    timestamp: datetime
    source_ip: str
    destination_ip: str
    event_type: str
    severity: int
    description: str

# Simulate rows from a database query
events = [
    SecurityEvent(
        datetime(2026, 2, 14, 9, 15, 33),
        "192.168.1.105", "10.0.0.1",
        "brute_force", 8,
        "Multiple failed SSH login attempts"
    ),
    SecurityEvent(
        datetime(2026, 2, 14, 9, 17, 12),
        "203.0.113.50", "10.0.0.5",
        "port_scan", 5,
        "Sequential port scan detected on range 1-1024"
    ),
]

# Named tuple fields make processing self-documenting
critical_events = [e for e in events if e.severity >= 7]
for event in critical_events:
    print(f"[{event.timestamp:%H:%M:%S}] {event.event_type} "
          f"from {event.source_ip} (severity: {event.severity})")
    print(f"  {event.description}")

Pattern 4: Composite Dictionary Keys

# Track metrics per (server, metric_name, hour) combination
from collections import defaultdict

metrics = defaultdict(list)

# Simulated metric data points
readings = [
    ("web-01", "cpu_percent", "2026-02-14T09:00", 45.2),
    ("web-01", "cpu_percent", "2026-02-14T09:00", 47.8),
    ("web-01", "cpu_percent", "2026-02-14T10:00", 62.1),
    ("db-01",  "cpu_percent", "2026-02-14T09:00", 31.5),
    ("web-01", "memory_mb",   "2026-02-14T09:00", 2048),
]

for server, metric, hour, value in readings:
    key = (server, metric, hour)  # Tuple as composite key
    metrics[key].append(value)

# Average each (server, metric, hour) bucket
for key, values in metrics.items():
    server, metric, hour = key
    avg = sum(values) / len(values)
    print(f"  {server} | {metric} | {hour} | avg={avg:.1f}")

Pattern 5: *args in Functions

When you define a function with *args, Python collects all positional arguments beyond the explicitly named parameters into a tuple — not a list. This design choice reinforces the principle that the received arguments should not be modified by the function.

def log_message(level, *args):
    """Log a message with variable arguments."""
    print(f"args type: {type(args)}")  # <class 'tuple'>
    message = " ".join(str(a) for a in args)
    print(f"[{level.upper()}] {message}")

log_message("info", "Server", "started", "on", "port", 8080)
# [INFO] Server started on port 8080
"There should be one -- and preferably only one -- obvious way to do it." — Tim Peters, The Zen of Python (PEP 20)

When your data is a fixed-structure record that should not change, the obvious way to represent it is a tuple. When your data is a variable-length collection that needs to grow and shrink, the obvious way is a list. Using the right type is not premature optimization — it is clear communication.

Key Takeaways

  1. Tuples are immutable sequences: Once created, their structure cannot be changed. This immutability is a feature, not a limitation — it communicates intent, enables hashability, and unlocks performance optimizations in CPython.
  2. Commas make tuples, not parentheses: The expression (42) is an integer. The expression (42,) is a tuple. Always include the trailing comma for single-element tuples.
  3. Unpacking is powerful: Use tuple unpacking for multiple return values, variable swaps, loop destructuring, and extended unpacking with the * operator (PEP 3132). These patterns make your code cleaner and more Pythonic.
  4. Tuples can be dictionary keys: Because they are hashable (when all elements are hashable), tuples can serve as composite keys in dictionaries and elements in sets — something lists can never do.
  5. Named tuples add clarity: When a regular tuple's positional access becomes unclear, switch to typing.NamedTuple. You get named fields, type hints, default values, and the full tuple interface with zero memory overhead.
  6. Type hints distinguish record tuples from sequence tuples: Use tuple[int, str, bool] for fixed-length records and tuple[int, ...] for variable-length homogeneous sequences (PEP 484, PEP 585).
  7. Tuples are faster than lists: CPython's free list, constant folding, and precise memory allocation give tuples measurable advantages in creation speed, memory usage, and iteration. Use them for data that does not need to change.
  8. Use tuples for heterogeneous records, lists for homogeneous collections: This is the guiding principle from Python's own documentation. A coordinate is a tuple. A list of coordinates is a list of tuples.
cd ..