What Does `…` (Ellipsis) Mean in Python 3?

I’ve lost count of how many times I’ve scanned a Python codebase, seen ..., and had to answer the same question: “Is that a typo, a comment, or some secret syntax?” It’s none of those. In Python 3, the three dots token (...) is a real expression that evaluates to a real built-in object named Ellipsis. That fact matters more than you’d think, because once you recognize it as an object—not punctuation—you start seeing clean patterns: intentional placeholders during refactors, sentinels for “argument not provided”, and compact indexing for high-dimensional arrays.

If you write modern Python in 2026—type checking, stub files, data-heavy workloads, and API design—you will run into Ellipsis regularly. I’ll walk you through what it is at runtime, why x is ... is the right way to check it, where it fits naturally (and where it doesn’t), and the mistakes that waste time in reviews and debugging. You’ll leave with practical patterns you can copy into production code, plus a mental model that makes ... feel boring—in the best way.

... is an object, not a comment

In Python, ... is syntax for the singleton object Ellipsis. You can reference it either way:

value = …

print(value) # Ellipsis

print(type(value)) #

if value is …:

print("Placeholder detected")

A few runtime facts I rely on when reasoning about code:

  • Ellipsis is a singleton, like None.
  • ... and Ellipsis are the same object.
  • The type is literally ellipsis.
  • Identity checks (is) are the correct way to test for it.

print(Ellipsis is …) # True

print(repr(…)) # ‘Ellipsis‘

print(bool(…)) # True

That last line surprises people: Ellipsis is truthy. So if you’re using it as a sentinel (a pattern I recommend in some APIs), never write if param: to test for “was it provided?” Use is Ellipsis explicitly.

Also, ... is an expression, which means you can place it where any expression can go: assign it, pass it as an argument, store it in a dict, or return it. That flexibility is why it ends up being useful.

What “singleton” really means in practice

When I say Ellipsis is a singleton, I mean the runtime gives you the same object every time you write ....

That has two practical consequences:

1) Identity is stable. If you pass ... through five layers of functions, x is ... will still be True on the other side.

2) You should treat it like None and NotImplemented: it’s a special marker, not “a value with meaning you compute with.”

If you ever find yourself writing arithmetic or string operations involving Ellipsis, that’s a smell. It might be fine in a debugging hack, but it’s almost never the clearest long-term choice.

Ellipsis vs NotImplemented vs NotImplementedError

These names all look similar, and I’ve watched experienced developers mix them up under pressure.

  • Ellipsis / ...: a singleton object; you can store it, compare by identity, and treat it as a marker.
  • NotImplemented: a singleton object that has a very specific protocol use: return it from certain “dunder” methods to tell Python to try the reflected operation (or fall back).
  • NotImplementedError: an exception you raise when something is intentionally not implemented.

If you’re implementing operators, NotImplemented can be correct. If you’re writing placeholder code, raise NotImplementedError is usually correct. If you’re writing “argument not provided,” ... can be correct.

Placeholder code: why ... often reads better than pass

When I’m sketching APIs, doing a staged refactor, or writing code that will be implemented later, I want the placeholder to scream “intentionally unfinished.” pass is fine, but it’s a statement with multiple meanings (sometimes you mean “do nothing forever”). ... is visually distinctive and strongly associated with “to be filled in.”

Here’s a minimal stub pattern:

def loadbillingprofile(customer_id: str) -> dict:

That is valid Python. The function returns None if called (because the expression statement does nothing), which can hide bugs if you accidentally call it. For that reason, in production code I often prefer a placeholder that fails loudly:

def loadbillingprofile(customer_id: str) -> dict:

raise NotImplementedError("Implement billing profile lookup")

So when should you keep ...?

  • In prototypes where code is not meant to run yet.
  • In type-driven development where you’re first establishing interfaces.
  • In .pyi stub files, where ... is idiomatic and expected.

A modern workflow I see a lot (and personally follow): I define the shape first (types, protocols, public function signatures), run pyright/mypy to validate the contract, then fill in implementations. ... is a clean marker in that “contract-first” stage.

A quick “placeholder choices” table

When reviewing code, I mentally bucket placeholders like this:

Intent

Best choice

Why I pick it —

— “This must be implemented before runtime”

raise NotImplementedError(...)

Fails loudly and early “This is a stub interface or stub file”

...

Standard in stubs; easy to scan “Do nothing by design”

pass

Communicates an intentional no-op “Operator overload fallback”

return NotImplemented

Correct protocol for binary ops

Note the difference between NotImplementedError (an exception you raise) and NotImplemented (a special value returned by dunder methods like eq). I’ve seen teams confuse those and ship subtle bugs.

My rule of thumb: “Will this line ever run?”

If the placeholder might run in any environment (dev, CI, prod), I assume it eventually will.

  • Library code: someone will call it.
  • Web service: some request path will reach it.
  • CLI: a user will discover the flag you forgot.

In those places, ... is too quiet. Use raise NotImplementedError so the failure is immediate, obvious, and points at the right line.

If the placeholder is truly “compile-time only,” like a .pyi stub or a protocol-only module that isn’t imported at runtime, ... is perfect.

A safer hybrid: use ... for readability, but guard execution

Sometimes I want the neatness of ... but the safety of a loud failure. In those cases, I’ll do one of these:

Option 1: explicit assertion

def loadbillingprofile(customer_id: str) -> dict:

assert False, "loadbillingprofile is not implemented"

Option 2: a helper

def _unimplemented(name: str):

raise NotImplementedError(f"{name} is not implemented")

def loadbillingprofile(customer_id: str) -> dict:

unimplemented("loadbilling_profile")

I’m not saying these are always better than a direct raise NotImplementedError, but they’re a reminder: you can keep intent readable while still failing fast.

Ellipsis as a sentinel: distinguishing “missing” from None

One of the most practical real-world uses is API design. In many domains, None is a valid value, so you need a way to tell the difference between:

  • the caller didn’t provide the argument, versus
  • the caller explicitly provided None.

You can do this with a dedicated sentinel object:

_MISSING = object()

def updateuseremail(userid: str, email=MISSING) -> None:

if email is _MISSING:

print("No email change requested")

return

# email may be None intentionally

print(f"Setting email to: {email!r}")

That’s a great pattern. But Ellipsis can play the same role with less boilerplate, especially for small modules:

def updateuseremail(user_id: str, email=…) -> None:

if email is …:

print("No email change requested")

return

print(f"Setting email to: {email!r}")

updateuseremail("u_123")

updateuseremail("u_123", None)

updateuseremail("u_123", "[email protected]")

I’ll be direct about my recommendation:

  • For public libraries, I still prefer a private sentinel like _MISSING = object() because it’s unambiguous and can’t be confused with “placeholder code.”
  • For internal code and quick scripts, email=... is perfectly readable when paired with if email is ...:.

What makes a good sentinel (and where Ellipsis fits)

When I choose a sentinel, I’m optimizing for a few properties:

1) It must be impossible to confuse with real values.

2) It must have stable identity.

3) It must be easy to type and read.

4) It should not serialize accidentally.

object() sentinels nail (1) and (2). Ellipsis nails (2) and (3) and usually (1) in well-written code. It fails (4) if you put it into JSON-like structures without thinking.

So I treat it like a “sharp tool” that’s great inside the boundary of Python runtime logic, but I’m careful when data crosses boundaries (JSON, database, network, templates).

Practical API pattern: “patch” semantics

Sentinels shine when you want patch-style updates, where each field can be:

  • missing (don’t touch it)
  • present and set to None (clear it)
  • present and set to a value (update it)

Here’s a simple (but real) shape for such an API:

from dataclasses import dataclass

@dataclass

class PatchUser:

display_name: str

None

ellipsis = …

timezone: str

None

ellipsis = …

def apply_patch(user: dict, patch: PatchUser) -> dict:

updated = dict(user)

if patch.display_name is not …:

updated["displayname"] = patch.displayname

if patch.timezone is not …:

updated["timezone"] = patch.timezone

return updated

This gives you three states per field without additional wrapper classes.

Dataclasses and defaults

If you use dataclasses, you’ll notice it already has its own “missing” mechanism. Still, there are times when you want a field to accept None but also want to represent “not set yet” explicitly.

from dataclasses import dataclass

@dataclass

class EmailPreferences:

marketingoptin: bool

None

ellipsis = …

prefs = EmailPreferences()

print(prefs.marketingoptin is …) # True

This can be helpful in multi-stage configuration pipelines (for example: defaults → org policy → user override), where None might mean “explicitly unset.” The key is to keep the type honest and always check with is ....

Pitfall: mutable defaults and ...

Ellipsis itself is immutable, so it’s safe as a default value. But don’t let that lull you into other default-argument mistakes.

This is still wrong (for the usual reasons):

def f(items=[]):

If you need “not provided,” use items=... and create the list inside:

def f(items=…):

if items is …:

items = []

return items

I like this form because it makes the “new list per call” behavior explicit.

Type hints: where ... shows up in modern typing

By 2026, static typing is mainstream in serious Python teams. Even if you don’t type everything, you’ll see ... in type signatures all the time.

Callable[..., ReturnType]: “any parameters”

When you see Callable[..., str], the ellipsis means: “this callable can accept any positional/keyword parameters; I only care that it returns str.”

from typing import Callable

def emitlog(message: str, logformatter: Callable[…, str]) -> None:

formatted = log_formatter(message, level="INFO")

print(formatted)

This is especially common in dependency injection and plugin systems, where you want to accept a wide variety of callbacks.

If you do care about the arguments, modern typing gives you better tools than Callable[..., ...] (for example, ParamSpec and Protocol). But the ellipsis form is still a clear signal: “I’m not constraining the inputs.”

#### A more precise alternative when you do care

I keep Callable[..., T] for truly “anything goes.” If the callback must accept specific arguments, I’d rather express that directly.

For example, a logger hook that must accept (message: str, level: str):

from typing import Callable

LogFormatter = Callable[[str, str], str]

def emitlog(message: str, level: str, logformatter: LogFormatter) -> None:

print(log_formatter(message, level))

Or, for advanced patterns where you want to preserve an arbitrary signature across layers, ParamSpec is the right mental model. The key point for this article: Callable[..., X] is where you’ll see ellipsis most often in typing.

tuple[int, ...]: “a variable-length tuple of ints”

This one is extremely common and often overlooked:

def median(samples: tuple[float, …]) -> float:

if not samples:

raise ValueError("Need at least one sample")

ordered = sorted(samples)

mid = len(ordered) // 2

return ordered[mid]

Here, the ellipsis doesn’t mean “placeholder.” It means the tuple can have any length, but every element must match the specified type.

If you’ve ever typed “a shape,” “a coordinate,” or “an index list,” you’ve probably used this.

#### A quick contrast: fixed-length vs variable-length tuples

  • tuple[int, int, int]: exactly three integers.
  • tuple[int, ...]: any number of integers.

This distinction matters a lot in numeric code and APIs that accept “N-dimensional shapes.”

Stub files (.pyi) and abstract interfaces

If you’ve ever opened stubs for a library, you’ve seen this pattern:

def connect(host: str, port: int) -> None: …

In stub files, ... is the idiomatic body. It’s compact, and tools understand that the implementation lives elsewhere.

In “real” .py code, I often choose raise NotImplementedError for abstract methods, but in .pyi and interface definitions, ... is part of the ecosystem.

... inside container type hints

You’ll also see ellipsis in “shape-like” type hints beyond tuples. The theme is consistent: ellipsis means “repeat / any length / unspecified parts.”

Common patterns I see in modern codebases:

  • “Any args accepted”: Callable[..., T]
  • “Any length tuple”: tuple[T, ...]

I mention this because it prevents a very specific confusion: if you see ... in a type hint, don’t assume it’s a runtime placeholder. It’s often a precise type-level signal.

Multidimensional slicing: the star feature in NumPy (and friends)

If you work with data, images, audio, ML tensors, or scientific computing, you’ll meet ... as an indexing shorthand.

The mental model

When you index an array-like object in Python, you’re effectively passing a tuple of index components into getitem. Those components can be:

  • integers (3)
  • slices (1:10)
  • None (often meaning “add a new axis” in NumPy)
  • Ellipsis (meaning “fill in the remaining dimensions”)

In NumPy, ... stands for “as many : slices as needed to complete the index.” In plain language: “keep everything in the other dimensions.”

A runnable NumPy example

If you want to run this locally:

  • Install NumPy: python -m pip install numpy

import numpy as np

tensor = np.arange(2 3 4).reshape(2, 3, 4)

# Pick the last column across all earlier dimensions

last_column = tensor[…, -1]

print("shape:", tensor.shape)

print("lastcolumn shape:", lastcolumn.shape)

print(last_column)

# Equivalent explicit form

same = tensor[Ellipsis, -1]

print("equal:", np.arrayequal(lastcolumn, same))

Why I like this in real code: it stays readable as dimensionality grows. Compare these:

  • tensor[:, :, :, :, -1] (easy to get wrong during refactors)
  • tensor[..., -1] (keeps intent stable even if you add/remove leading dims)

This pattern isn’t limited to NumPy. You’ll see it in PyTorch, JAX, TensorFlow tensors, and many custom array libraries.

A common error: only one ellipsis is allowed

Array indexers typically allow at most one Ellipsis per index expression. Trying multiple is usually an error:

import numpy as np

a = np.zeros((2, 2, 2))

try:

_ = a[…, 1, …]

except Exception as exc:

print(type(exc).name + ":", exc)

The reason is simple: once ... already means “fill in whatever dimensions are missing,” a second one is ambiguous.

A subtle gotcha: ... expands relative to the number of dimensions

This is the part that makes ellipsis powerful: it adapts.

If tensor is (2, 3, 4), then tensor[..., -1] behaves like tensor[:, :, -1].

If you later change the shape to (8, 2, 3, 4) (say you add a batch dimension), the same expression behaves like tensor[:, :, :, -1].

That “dimension-agnostic intent” is exactly why I prefer it.

Performance considerations (practical, not theoretical)

In NumPy-like libraries, the performance story is usually about views vs copies:

  • Many slices (including ones that use ...) produce views when possible.
  • Some indexing modes (advanced indexing with arrays/lists, boolean masks) can produce copies.

... itself is not “slow” or “fast.” It’s just a shorthand. The thing that affects performance is the kind of indexing you’re doing. I bring this up because I’ve heard people blame ellipsis for performance when the real issue was switching from slicing to advanced indexing.

If you’re performance-sensitive, my workflow is:

1) Write the clearest indexing expression (often with ...).

2) Verify whether it returns a view or copy (library-dependent).

3) Adjust if needed (for example, prefer slicing when you can).

The REPL prompt ... is a different thing

There’s one more place you see three dots: the interactive interpreter’s continuation prompt.

>>> for i in range(3):

… print(i)

0

1

2

That ... is not the Ellipsis object. It’s just the REPL’s UI telling you Python is waiting for more input to complete a block.

I mention this because beginners often connect these two and assume ... “means a block.” It doesn’t. In files and expressions, ... evaluates to Ellipsis. In the REPL, ... is just a prompt string.

A quick sanity check:

# In a normal .py file, this prints Ellipsis

print(…)

So if you see ... indented under for in the REPL, that’s not code you wrote—it’s the interpreter talking to you.

When NOT to use Ellipsis (and what to do instead)

Because ... looks like “unfinished,” it’s easy to overuse it. Here are the situations where I advise against it.

1) Don’t keep ... placeholders in code paths that execute

If your service might call the function, stubbing with ... can fail silently and propagate None until something crashes far away from the real cause. Prefer:

  • raise NotImplementedError(...) for “must implement”
  • pass for “no-op by design”

2) Don’t test it with ==

Identity is the right check:

sentinel = …

# Good

if sentinel is …:

print("missing")

# Avoid

if sentinel == …:

print("this is not the idiom")

Ellipsis doesn’t define fancy equality semantics, but the habit of using is for singletons (None, Ellipsis, NotImplemented) prevents subtle mistakes.

3) Don’t confuse Ellipsis with “any” in runtime code

In typing, Callable[..., str] is meaningful to the type checker. At runtime, ... is just an object. This is a common mistake when people try to build dynamic validators:

# Bad idea: treating Ellipsis as a wildcard in runtime logic

allowed = {"status": …}

If you want runtime wildcards, define an explicit sentinel like ANY = object() and document its meaning.

4) Don’t serialize it to JSON and expect it to work

If you put Ellipsis in structures that get JSON encoded, you’ll hit errors because JSON has no Ellipsis literal. If you need a “missing” marker in serialized data, convert it to null, omit the field, or use a string token that your system agrees on.

5) Don’t use ... as “temporary commented-out code”

I sometimes see developers use ... the way they might use TODO comments—dropping it in the middle of logic to “skip a section.”

That’s risky because it changes runtime behavior in a way that’s not always obvious. If you need to disable behavior temporarily, I’d rather see:

  • a feature flag
  • an explicit if False: block (in a throwaway branch)
  • a TODO comment plus deletion of dead code

... is best when it clearly means either “missing sentinel” or “placeholder body,” not “I didn’t want to delete this yet.”

Patterns I recommend in production code (2026 edition)

Here’s how I tend to apply Ellipsis in modern Python teams that care about readability, tooling, and long-term maintenance.

Pattern A: Sentinel defaults with explicit checks

Good for request builders, optional configuration, and patch-style updates:

from dataclasses import dataclass

@dataclass

class PatchUser:

display_name: str

None

ellipsis = …

timezone: str

None

ellipsis = …

def apply_patch(user: dict, patch: PatchUser) -> dict:

updated = dict(user)

if patch.display_name is not …:

updated["displayname"] = patch.displayname

if patch.timezone is not …:

updated["timezone"] = patch.timezone

return updated

This gives you three states per field: not provided (...), explicitly set to None, or set to a value.

Pattern B: Array/tensor slicing that survives shape changes

When your tensors gain a batch dimension or extra channels, ... keeps your intent stable:

# Works for (H, W, C) and also (N, H, W, C)

alpha_channel = image[…, 3]

Pattern C: Type stubs and protocols

If you maintain internal stubs for faster type checking or clean boundaries between packages, ... is the standard body marker.

Tooling note: linters like Ruff can be configured to accept ... in stubs and in abstract contexts while warning about it elsewhere. That’s a good compromise: you get the readability benefits without accidentally shipping placeholders.

Pattern D: “Explicitly unset” in configuration pipelines

When I’m writing configuration loaders, I often need three states:

  • not provided (keep searching in lower-precedence sources)
  • provided as None (explicitly clear / disable)
  • provided as a value (use it)

I’ll model the intermediate representation with ellipsis:

def merge_setting(current, incoming=…):

if incoming is …:

return current

return incoming

Then, right before I cross a boundary (like writing JSON), I normalize:

  • ... becomes “field omitted”
  • None becomes JSON null

This keeps “missing vs explicit” semantics inside Python where it’s easy to reason about.

Under the hood: how Python represents ...

I find Ellipsis easier to use correctly once you know how boring it is under the hood.

It’s a literal in the grammar

Just like None, True, and False are special literals, ... is also a literal expression in Python’s syntax. That’s why you don’t need an import and why it works anywhere an expression works.

It shows up plainly in AST

If you ever inspect the AST (for lint rules, codegen, or tooling), ... is not magic punctuation; it’s a concrete node.

A quick script you can run to see this:

import ast

tree = ast.parse("x = …")

print(ast.dump(tree, indent=2))

You’ll see a representation that includes an ellipsis constant. That’s a nice confirmation: Python treats it like data.

It compiles like any other constant

At the bytecode level, ... behaves like loading a constant and discarding it (if it’s a standalone expression statement) or storing it (if you assign it). This matters because it explains the “silent None return” behavior for stubbed functions.

When you write:

def f():

You didn’t “return ellipsis.” You evaluated the Ellipsis object and then did nothing with it. The function then ends, so it returns None.

If you actually want to return it (usually you don’t), you must say so:

def f():

return …

I rarely recommend returning ellipsis from public functions, but it can be useful in tests or as an internal sentinel value.

Custom containers: supporting ... in your own getitem

Most people meet ellipsis through NumPy, but you can support it in your own classes too. This is especially helpful for “tensor-like” objects, record arrays, or DSL-style indexing.

The key insight: in obj[a, b, c], Python passes a tuple to getitem.

So obj[..., -1] becomes something like:

  • key is a tuple
  • one element of that tuple is literally Ellipsis

Here’s a minimal example that prints what it receives:

class DebugIndexer:

def getitem(self, key):

print("key:", key)

return key

d = DebugIndexer()

d[1]

d[1:10]

d[…, -1]

d[Ellipsis, -1]

If you build something that wants to treat ellipsis as “expand the rest,” you can implement that expansion yourself.

A practical mini-pattern: normalize keys

When I implement getitem, I often normalize the incoming key into a tuple of a known length.

Pseudocode idea (not a full tensor implementation):

def normalize_key(key, ndim):

if not isinstance(key, tuple):

key = (key,)

if key.count(Ellipsis) > 1:

raise IndexError("an index can only have a single ellipsis")

if Ellipsis in key:

i = key.index(Ellipsis)

missing = ndim – (len(key) – 1)

key = key[:i] + (slice(None),) * missing + key[i+1:]

return key

That is essentially the mental model you’re relying on when you write array[..., -1].

Linting and review hygiene: keeping ... from shipping accidentally

If you like using ... during development (I do), you want guardrails so you don’t ship it where it can execute.

A simple approach: treat executable ellipsis as a bug

Teams often adopt a rule like:

  • ... is allowed in .pyi.
  • ... is allowed in explicitly abstract methods.
  • ... is not allowed in concrete runtime code.

Whether you enforce that via linter configuration, code review, or a custom check, the goal is the same: placeholders shouldn’t silently escape.

A review trick I use: “What happens if it runs?”

When I see ... in a .py file, I ask:

  • If this line executes, what happens?
  • Does it return None and hide the error?
  • Would it be better as raise NotImplementedError?

That question is quick, and it catches the most expensive class of ellipsis mistakes.

Key takeaways and what I’d do next

If you remember only a few things, make them these:

  • ... is Ellipsis, a real singleton object, and Ellipsis is ... is True.
  • The correct test is identity: value is ....
  • As a placeholder, ... is readable, but it can fail silently if executed—raise NotImplementedError is safer for runtime paths.
  • In typing, ellipsis has specific meanings in places like Callable[..., ReturnType] and tuple[T, ...].
  • In scientific and ML code, array[..., index] is one of the cleanest ways to express “select along the last axis regardless of leading dims.”

If you want to put this to work immediately, I’d start by doing a quick scan of your codebase for ...:

1) If it’s in .pyi or interface-only modules, it’s probably fine.

2) If it’s in code that can run in production, replace it with raise NotImplementedError or a real implementation.

3) If you have APIs that currently use None to mean both “missing” and “explicitly clear,” pick a sentinel strategy (... for internal code, or _MISSING = object() for public APIs) and migrate so callers can express intent clearly.

A migration checklist I actually follow

If you decide to introduce ellipsis sentinels into an existing API, here’s the path I take:

  • Add the sentinel default without changing behavior (treat None the same as before for one release cycle if you need to).
  • Update internal callers first to pass None explicitly when they mean “clear,” and omit the argument when they mean “don’t change.”
  • Add logging or assertions in development builds to catch ambiguous usage.
  • Only then tighten semantics and update docs.

That staged approach avoids the classic “we changed a default and broke half the codebase” moment.

FAQ: quick answers to common ellipsis questions

“Is ... the same as pass?”

No. pass is a statement that does nothing. ... is an expression that evaluates to the Ellipsis object. In many contexts, both can be used as “placeholder,” but they behave differently in subtle ways (notably inside functions where ... can silently lead to None returns).

“Can I import Ellipsis?”

You don’t need to. It’s a built-in name available by default.

“Should I ever compare it with ==?”

Don’t. Use is / is not. That’s the standard pattern for singleton markers in Python.

“Why is it truthy?”

Because it’s just a normal object, and normal objects are truthy unless they define falsy behavior. The practical implication: never rely on truthiness checks when ... is a possible value.

“Is it okay to store ... in dicts/lists?”

Yes, at runtime it’s fine. But think about boundaries: if that structure might be serialized (JSON, msgpack, etc.), you’ll need to normalize ... into something representable.

“If ... is in a type hint, will it do anything at runtime?”

No. In type hints, ellipsis is for type checkers. At runtime it’s still just the Ellipsis object, and it won’t validate anything unless you write code that interprets it.

Scroll to Top