A list is mutable and variable-length — use it for a homogeneous,
changing collection you add to, remove from, or reorder. A tuple is
immutable and fixed — use it for a heterogeneous, fixed record whose
shape won't change, like a coordinate or a database row.
scores = [90, 85, 70] # changing collection -> list
scores.append(60) # fine
point = (3, 4) # fixed record -> tuple
point[0] = 9 # TypeError — tuples are immutable
Tuples are also slightly faster and more memory-efficient, and because they're hashable they can serve as dict keys or set members (a list can't). Rule of thumb: reach for a tuple when the data is a fixed bundle that shouldn't change, and a list when you need to mutate the collection.
Packing collects several values into one tuple — the parentheses are often optional. Unpacking spreads a tuple's items into separate names in one assignment, which is how you return and receive multiple values cleanly.
t = 1, 2, 3 # packing (parentheses optional)
a, b, c = t # unpacking -> a=1, b=2, c=3
first, *rest = t # extended unpacking -> first=1, rest=[2, 3]
one = (5) # NOT a tuple — just int 5 in parentheses
one = (5,) # a 1-element tuple — the trailing comma makes it
The key gotcha is the single-element tuple: it's the trailing comma, not
the parentheses, that creates a tuple — (5) is just the integer 5, while
(5,) is a one-tuple. When in doubt, the comma is what defines a tuple.
A tuple's immutability is shallow. You can't reassign or resize its slots, but each slot just holds a reference — and if that reference points to a mutable object (like a list), that object can still be changed in place.
t = (1, [2, 3])
t[1].append(4) # allowed — mutating the list inside the tuple
print(t) # (1, [2, 3, 4])
t[1] = [9] # TypeError — can't reassign a tuple slot
A consequence interviewers love: a tuple that contains a list is not
hashable, because hashability requires every element to be immutable too —
so hash((1, [2])) raises TypeError. Bottom line: the tuple's structure is
frozen, but the objects it points to may not be.
A namedtuple is an immutable tuple subclass with named fields — giving you readable, hashable, lightweight records that still behave like tuples (index access, unpacking, comparison). There are two ways to define one.
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x, p[0] # 3, 3 — named AND index access
p.x = 9 # AttributeError — immutable
from typing import NamedTuple
class Point(NamedTuple): # class syntax with type hints + defaults
x: int
y: int = 0
The collections form is concise; the typing.NamedTuple class form adds
type annotations, defaults, and the ability to add methods. Use a namedtuple
for small fixed records when you want clarity over a bare tuple; reach for a
@dataclass when you need mutability or richer behavior.
Dict keys and set members must be hashable, which in practice means immutable with a stable hash for the object's lifetime. A tuple of immutable values is hashable, so it works as a key; a list is mutable and unhashable, so it doesn't.
grid = {}
grid[(0, 0)] = "origin" # tuple key — works
grid[(1, 2)] = "point"
grid[[3, 4]] = "x" # TypeError: unhashable type: 'list'
This makes tuples ideal for composite / multi-part keys — like (row, col)
coordinates or (lat, lon) pairs. The one caveat: a tuple is only hashable if
all its contents are too, so (1, [2]) still fails. Use an immutable tuple
whenever you need a multi-value key.
No — it's the commas that make a tuple, not the parentheses. 1, 2, 3
is a tuple; the parens just group for clarity or precedence. That's why
functions "return multiple values" — they return one tuple built by commas.
t = 1, 2, 3 # (1, 2, 3)
def f(): return 1, 2 # returns the tuple (1, 2)
x = (5) # int 5 ! -> need (5,) for a 1-tuple
Rule of thumb: the comma creates the tuple; add parentheses for readability or when a bare comma would be ambiguous.
Yes, modestly. Tuples use less memory and construct slightly faster because they're fixed-size and immutable — CPython can even cache small tuple objects and store constant tuples directly in bytecode. For large hot-path data this adds up.
import sys
sys.getsizeof((1, 2, 3)) # smaller
sys.getsizeof([1, 2, 3]) # larger (over-allocates for growth)
Rule of thumb: use a tuple for fixed, read-only groups of values; the immutability buys safety and a small efficiency win.
Only two: count(x) and index(x). Tuples are immutable, so there's
no append, sort, remove, etc. To "change" a tuple you build a new one
(e.g. via concatenation or sorted(), which returns a list).
t = (1, 2, 2, 3)
t.count(2) # 2
t.index(3) # 3
sorted(t) # [1, 2, 2, 3] -> a new LIST, not a tuple
Rule of thumb: tuples are for fixed data; if you need mutation methods, you want a list.
It adds named field access (p.x) on top of normal tuple behavior, plus
helpers: ._fields, ._asdict(), ._replace(...) (returns a new
one), and ._make(iterable). It stays a real tuple — indexable, unpackable,
and hashable.
from collections import namedtuple
Point = namedtuple("Point", "x y")
p = Point(1, 2)
p.x, p[0] # 1, 1
p._replace(y=9) # Point(x=1, y=9) -> new tuple
p._asdict() # {'x': 1, 'y': 2}
Rule of thumb: use a namedtuple for lightweight, immutable records when you want self-documenting field names without writing a class.
Use a namedtuple when you want an immutable, tuple-like record that's
indexable/unpackable and memory-light. Use a dataclass when you need
mutability, methods, defaults with logic, type-checked fields, or
inheritance. frozen=True dataclasses overlap with namedtuples but aren't
tuples.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def dist(self): return (self.x**2 + self.y**2) ** 0.5
Rule of thumb: namedtuple for simple immutable value records; dataclass when you need behavior, mutability, or richer typing.
a, b = b, a works because the right side is evaluated to a tuple first,
then unpacked into the targets. No temporary variable is needed — Python
builds (b, a) and assigns both names at once.
a, b = 1, 2
a, b = b, a # a=2, b=1
x, y, z = z, x, y # rotate three values
Rule of thumb: use tuple unpacking for swaps and multi-assignment; it's clearer and avoids a scratch variable.
A function's *args collects extra positional arguments into a tuple.
Conversely, a tuple can be splatted back into positional arguments with
*. This is the same packing/unpacking machinery tuples use everywhere.
def f(*args):
print(type(args)) # <class 'tuple'>
nums = (1, 2, 3)
f(*nums) # unpack tuple -> f(1, 2, 3)
Rule of thumb: *args is always a tuple; use *tuple to feed its items as
separate positional arguments.
+ and * produce new tuples (the originals are unchanged, since tuples
are immutable). This means repeated concatenation in a loop is O(n²) —
build a list and convert once if you're accumulating.
(1, 2) + (3,) # (1, 2, 3) new tuple
(0,) * 3 # (0, 0, 0)
# avoid: t = (); for x in xs: t += (x,) # quadratic
Rule of thumb: tuple +/* is fine for one-offs; accumulate in a list and
convert to a tuple at the end.
Lexicographically, element by element: compare the first items, and only if equal move to the next. This makes tuples a natural multi-key sort key and lets you compare versions or coordinates directly.
(1, 2) < (1, 3) # True -> first equal, 2 < 3
(2, 0) < (1, 9) # False -> 2 > 1 decides immediately
sorted(people, key=lambda p: (p.last, p.first))
Rule of thumb: return a tuple as a sort key to sort by multiple fields in
priority order.
Because parentheses are just grouping; without a comma (5) is the
integer 5. The trailing comma is what creates a one-element tuple. This
trips people up with single-item tuples and function calls.
type((5)) # <class 'int'>
type((5,)) # <class 'tuple'>
type(5,) # also a tuple -> (5,)
Rule of thumb: always add the trailing comma for single-element tuples; the comma — not the parens — is the tuple.
More Data Structures interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.