skip to content

How does `tuple[int, ...]` differ from `tuple[int, str]` in a signature?

level: middleimportance: should knowfreq 44%

answer

  1. Two ways people actually use tuples
  2. Ellipsis means more of the same
  3. One type per position fixes the arity
  4. Bare tuple, and the empty-tuple spelling
  5. Shape is checked, meaning is not

basics

~10 s

tuple[int, ...] is a homogeneous tuple of any length, including empty. tuple[int, str] is exactly two elements: an int then a str. The literal ellipsis means 'more of the same', never 'and whatever else'.

solid answer

~40 s

The comma-separated form fixes both the length and the type of each position: `tuple[int, str]` is a two-element tuple whose first item is an `int` and whose second is a `str`, so a checker lets you unpack `start, name = t` and knows `t[0]` is an `int`. The `...` form is different in kind — `tuple[int, ...]` is a variable-length tuple whose every element is an `int`, of any length including zero, so unpacking into a fixed number of names is an error and only membership and iteration are safe. Bare `tuple` means `tuple[Any, ...]`, and `tuple[()]` is the empty tuple. The `...` here is the literal `Ellipsis` token and is special-cased for `tuple`; it does not mean 'unspecified rest'.

code

python · 11 lines
python
def page_bounds(total: int, size: int, page: int) -> tuple[int, int]:
    start = page * size
    return start, min(start + size, total)

def quantities(rows: list[tuple[str, int]]) -> tuple[int, ...]:
    return tuple(qty for _, qty in rows)

start, stop = page_bounds(6800, 500, 13)
print(start, stop)
print(quantities([("SKU-1", 3), ("SKU-2", 5), ("SKU-3", 8)]))
print(quantities([]))

go deeper

for a junior

Be able to read both forms out loud: tuple[int, str] is exactly two items of those types in that order, tuple[int, ...] is any number of ints. Know that the three dots are a literal part of the syntax, not a placeholder you fill in.

for a middle

Explain the choice: is the length part of the contract or not? Cover the corners too — bare tuple means tuple[Any, ...], tuple[()] is the empty tuple, and *args: int annotates each argument while the body sees tuple[int, ...].

for a senior

Show what the fixed form still misses. Two ints of the same type say nothing about which is start and which is stop, so an inclusive-versus-exclusive mix-up passes the checker; argue for named fields when the positions carry meaning.

for a principal

Own the convention across an API surface: where the codebase returns bare tuples, where it returns named records, and how much annotation precision is worth in exchange for churn when a return value grows. Encode the answer in review guidance, not in individual taste.

### Tuples are the one container with two shapes Every other builtin container is uniform: a `list[int]` is any number of ints, a `set[str]` any number of strings, a `dict[str, int]` any number of string-to-int pairs. Tuples are used two very different ways in Python — as a fixed record (`(sku, quantity)`) and as an immutable sequence (`(3, 5, 8, 13)`) — and the annotation syntax has to express both. **The fixed form** lists one type per position: `tuple[str, int]`, `tuple[int, int, bool]`. The arity is part of the type. A checker knows `t[0]` is a `str` and `t[1]` is an `int` when the index is a literal, allows `sku, qty = t`, and rejects a three-element value. **The variadic form** is `tuple[X, ...]`: any number of `X`, zero included. Here `...` is the literal `Ellipsis` object, special-cased by the type system for `tuple`, and it means "more elements of that same type" — never "and then anything else". Because the length is unknown, `a, b = t` does not check, and neither does `t[2]` in general; iteration, `len()`, `in` and slicing are what you get. Two corners round it out: a bare `tuple` with no subscript means `tuple[Any, ...]`, and `tuple[()]` is the type of the empty tuple, occasionally useful as a base case. ### Why picking the wrong one hurts Annotating a fixed pair as `tuple[int, ...]` throws away everything the checker could have done for you. Consider a pick-list builder that slices a **6,800-row** batch into pages and returns the bounds of the current page: ```python def page_bounds(total: int, size: int, page: int) -> tuple[int, int]: start = page * size return start, min(start + size, total) ``` With `tuple[int, int]`, `start, end = page_bounds(6800, 500, 3)` type-checks and a three-name unpack is caught immediately. Annotate the same return as `tuple[int, ...]` and the unpack becomes unverifiable — the checker cannot tell two from three — so a caller that grows the return value later gets no warning at all. The reverse mistake is just as common: annotating `quantities(rows) -> tuple[int, int]` because today's fixture happens to hold two rows. The moment a third row arrives every call site is a type error for a function that was never really fixed-arity. Ask the honest question — *is the length part of the contract?* If yes, the fixed form; if no, the ellipsis form. ### What the fixed form still will not catch This is the senior half of the answer. `tuple[int, int]` says "two ints". It does not say *which* int is which. A page-bounds function that returns `(end, start)` by mistake, or whose second element is an **inclusive** last row while the caller assumes an exclusive stop, type-checks perfectly and produces an off-by-one that only shows up at the edge of the batch — the 6,800th row silently dropped or double-picked. Positional types check shape, not meaning. When the positions carry meaning, name them. `typing.NamedTuple` gives you the names, the annotation and tuple behaviour at once: ```python from typing import NamedTuple class PageBounds(NamedTuple): start: int stop_exclusive: int ``` Now the boundary convention is in the identifier, unpacking still works for callers that want it, and swapping the two fields is a visible mistake at the construction site. A `dataclass` is the same move when tuple behaviour is not wanted. The rule of thumb that survives review: a two-element return of obviously different things is fine as a plain tuple; three or more, or two of the same type where order carries meaning, wants names. ### Reading the forms in context A few spellings you will meet constantly. `list[tuple[str, int]]` is the standard shape of tabular rows — a list of (key, value) pairs, which is exactly what iterating `dict.items()` gives you. `dict[str, tuple[int, ...]]` maps a SKU to an arbitrary run of counts. `*args` in a function is a `tuple`, and annotating `def f(*args: int)` means each argument is an `int`, so the collected `args` is `tuple[int, ...]` — you annotate the element type, not the tuple. ### Runtime versus checking None of this is enforced when the code runs. `tuple[int, str]` evaluates to a `types.GenericAlias`; constructing `(1, 2)` where `tuple[int, str]` was promised raises nothing. The annotation exists for the checker, the reader and the editor. That is why choosing between the two forms is a communication decision, and why the fixed form is worth using whenever the arity really is fixed: it is free documentation that a tool can verify.

  • What does a bare `tuple` annotation with no subscript mean?
    It is equivalent to `tuple[Any, ...]`: a tuple of any length whose elements are unchecked. It silences the checker rather than describing anything, so it is worth avoiding except as a deliberate escape hatch. The same applies to bare `list` or `dict`, which mean `list[Any]` and `dict[Any, Any]`.
  • How do you annotate `*args` when every extra argument is an int?
    Write `def f(*args: int)`. You annotate the type of each individual argument, not the collected container; inside the body `args` has type `tuple[int, ...]`. The parallel rule holds for `**kwargs: int`, where the annotation describes each value and the body sees `dict[str, int]`.
  • When would you return a NamedTuple instead of tuple[int, int]?
    When the positions mean different things and the order is easy to get wrong — bounds, coordinates, ranges with an inclusive-or-exclusive convention. `tuple[int, int]` checks that there are two ints and nothing about which is which, so swapping them or mistaking an exclusive stop for an inclusive last index passes the checker. Field names put the convention in the code and still unpack like a tuple.

tuple[int, str] is a form with two labelled boxes; tuple[int, ...] is a roll of identical tickets that can be any length.

saying these in an interview costs you the question

  • Reads tuple[int, ...] as 'an int then anything else'
  • Thinks tuple[int, ...] cannot be empty
  • Uses tuple[int, ...] for a fixed pair of return values
  • Says a bare tuple annotation means an empty tuple
  • Believes tuple[int, str] is enforced when the code runs
  • Claims a fixed tuple type prevents swapping the two values

context