induwara.lk
Opinionpythonprogramming-languagesdeveloper-education

Python's six constants follow five different rules

Python's pre-declared constants behave five different ways for six names. Here's why that matters for your tests, your interviews, and how you read any language.

Induwara Ashinsana5 min read

Python's pre-declared constants are the six names you have typed a thousand times without thinking: True, False, None, __debug__, Ellipsis and NotImplemented. A developer writing as sebsite poked at all six in Python's pre-declared constants are kinda weird, and found that six names produce roughly five different sets of rules.

That is the part worth your time. Not the trivia, but what an irregular corner of a very popular language tells you about how software actually gets built.


🔍 Six names, five different rulebooks

The clean summary is that these names are not one category at all. They are three or four categories wearing the same coat.

Name Assign to it Shadow via builtins What the literal form does
True / False / None SyntaxError (keywords) setattr "works", name still resolves normally Handled by the grammar, not name lookup
__debug__ SyntaxError: cannot assign to __debug__ builtins.__debug__ changes, bare __debug__ does not Compile-time value, -O flips it
Ellipsis Plain assignment works Shadowing works ... keeps returning the real Ellipsis
NotImplemented Plain assignment works Shadowing works No literal form to protect it

The demonstrations in the article are short enough to retype from memory:

NotImplemented = 67
NotImplemented          # 67

__debug__ = 67          # SyntaxError: cannot assign to __debug__
del __debug__           # SyntaxError: cannot delete __debug__

x.True                  # SyntaxError
x.__debug__             # AttributeError, because it parses fine
x.__debug__ = 67        # SyntaxError again

sebsite makes one claim I had never registered before: __debug__ is the only identifier in the language you cannot assign to. Not a keyword, not reserved in the usual sense, but still off limits to the compiler.


⚙️ The tokenizer is doing more than you think

The explanation is not "Python protects important values." It is that these names are enforced at different stages of the pipeline, and each stage has different powers.

  1. Tokenizer / grammar. True, False and None are keywords. That is why x.True is a syntax error even though attribute names are otherwise free-form. The parser never gets far enough to care what x is.
  2. Compiler. __debug__ is resolved when your code is compiled, which is what lets the -O flag bake in a different value.
  3. Runtime namespace. Ellipsis and NotImplemented are ordinary builtins. Nothing special guards them.

The sharpest demonstration in the piece is this pair:

import builtins
setattr(builtins, 'Ellipsis', 67)
Ellipsis    # 67
...         # Ellipsis

Key takeaway: The name and the literal are two separate paths to the same object, and patching one does not touch the other. If a value has a literal syntax, your monkeypatch will not reach code that uses the literal.

Same story with setattr(builtins, 'True', 67). The builtins entry changes. Every True you actually wrote keeps working, because those were never namespace lookups to begin with.


🛠️ Where this bites real code

This is not purely academic, and the practical lesson is about test seams.

I have seen people reach for builtins patching when a value feels global enough to be worth intercepting. The article is a clean argument for why that instinct is unreliable:

  • Patching builtins only affects code paths that perform a name lookup.
  • Anything compiled to a constant, or expressed as a literal, is already past the point where your patch could apply.
  • The failure is silent. Your patch appears to succeed, getattr confirms the new value, and the code under test keeps using the old one.

That is the worst shape a bug can have: a test that passes for a reason unrelated to the thing you meant to verify.

The second practical point is about __debug__. Because it is compile-time, if __debug__: blocks are not runtime configuration. You cannot flip it from a settings file or an environment variable read at startup. If you want a debug switch, write your own; do not borrow this one.


🎓 The cheapest deep learning available to a student here

This is the angle I care most about for readers in Sri Lanka. If you are at UCSC, Moratuwa, SLIIT, NSBM or teaching yourself from home, most "go deeper" advice ends up costing money. Cloud credits, paid courses, GPU time, a Udemy sale you keep missing.

Language internals cost nothing.

  • You need one interpreter and about twenty minutes.
  • The dis module is already installed, and it will show you exactly what your source compiled into.
  • Every claim in that article is falsifiable by you, in a REPL, right now.

If you do not want to set up a local environment on a shared or borrowed machine, you can run all of these snippets in our online Python compiler in the browser. No install, no signup.

Try this as a starting exercise:

import dis
dis.dis(lambda: (True, None, ..., NotImplemented))

Then compare what the bytecode does with a name versus a literal. That single comparison explains the whole article better than any summary I can write, including this one.

This kind of exercise is also genuinely useful for interviews. "Why does x.True fail but x.__debug__ raise AttributeError instead?" is the sort of question that separates someone who has read the manual from someone who has read the machine.


💡 Irregularity is what history looks like

The article's honest conclusion is that there is no single clean rationale here. Some values got special treatment, functionally similar ones did not, and the result is inconsistent.

I would put it slightly differently. This is what accretion looks like in a language that has been maintained for decades by people making locally reasonable decisions. None became a keyword because assigning to it was a real source of pain. NotImplemented never got the same treatment because nobody was accidentally clobbering it often enough to justify a grammar change.

Your own codebase is the same. Ask why one module validates input at the edge and another validates in the handler, and the answer is almost never a design principle. It is a bug from 2023 and whoever was on call.


What this means for you

  • Do not use builtins patching as a test seam. Literals and compile-time constants bypass it entirely, and the failure is silent.
  • Do not treat if __debug__: as runtime configuration. It is fixed at compile time. Write your own flag.
  • Assume Ellipsis and NotImplemented are shadowable, because they are. If you rely on them in a library, use ... and the identity check rather than the bare name.
  • Spend an evening with dis. It is the highest ratio of understanding gained to money spent that I know of in this field.
  • Read irregularity as history, not as failure. When a language surprises you, the useful question is which stage of the pipeline is responsible, not who was careless.

Six names, five rulebooks, zero cost to verify any of it yourself. That is a decent trade.

#python#programming-languages#developer-education
IA

Induwara Ashinsana

Information Systems student at UCSC and Executive Director at Ryzera Technologies. Writes about software, AI, and what it means for builders in Sri Lanka.

About the author →

Keep reading