Whetstone.
The SpellingTruthiness, None, and the mutable default
Module 1 · Lesson 314 min

Truthiness, None, and the mutable default

Four behaviours in this lesson. Each one runs without error while doing something other than what a TypeScript reflex expects, which makes them more expensive than syntax you get wrong loudly.

Empty containers are falsy

All of these are False in a condition
bool([])       # False
bool({})       # False
bool("")       # False
bool(set())    # False
bool(0)        # False
bool(None)     # False

In JavaScript, [] and {} are objects and objects are truthy, so if (results) tells you the variable holds something. In Python if results: tells you the variable holds something non-empty. Those are different questions, and Python’s answer is the more useful one about ninety percent of the time.

The idiomatic checks
if not results:                # empty OR None. Usually what you want.
    return

if results is None:            # genuinely absent, distinct from empty
    results = fetch()

The trap is a function whose parameter is optional and whose empty case is meaningful. if not items: treats “the caller passed nothing” and “the caller passed an empty list on purpose” as the same event. Sometimes correct, sometimes a silent bug, and you have to decide which per function rather than by reflex.

None is the only absence

There is no undefined. A name is either bound or it is not, and reading an unbound name raises NameError or UnboundLocalError rather than evaluating to a second kind of nothing.

That collapses a distinction you are used to carrying, and it removes a whole family of TypeScript defensive code. It also removes the tools built around it: there is no optional chaining and no nullish coalescing.

What you write instead of a?.b and a ?? b
name = user.name if user is not None else None    # no a?.b
timeout = configured if configured is not None else 30   # no a ?? b

The second one has a shorter idiom, timeout = configured or 30, which is exactly || and carries exactly the same bug: it also replaces 0, "" and False. It is fine for a string that cannot legitimately be empty and wrong for anything numeric.

Always test with is None, never == None. is compares identity, None is a singleton, and a class is free to define __eq__ in a way that makes x == None return something you did not expect. Every linter in the language flags == None.

is against ==

== compares value. is compares identity, meaning the same object in memory.

Coming from TypeScript there is a tempting false mapping: == is the loose one, === is the strict one, so is must be ===. It is not. Python’s == already does no type coercion, so it is already the strict comparison you wanted. is is a third thing with no TypeScript equivalent except reference equality.

Why this bites intermittently
a = 256
b = 256
a is b        # True, in CPython. Small ints are cached.

a = 257
b = 257
a is b        # False. Outside the cache.

Nothing about that is guaranteed by the language and none of it is something to rely on. It matters only because it means is on integers and strings appears to work in a REPL and on small test data, then fails on a real value. Use is for None, True and False. Nothing else.

The mutable default argument

This one is famous, and the fame is deserved.

The bug
def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags

add_tag("a")    # ['a']
add_tag("b")    # ['a', 'b']   <- the same list, still there
add_tag("c")    # ['a', 'b', 'c']

The default expression is evaluated once, when the def statement runs, and the resulting object is stored on the function. Every call that omits the argument gets that same object. If the object is mutable, mutations accumulate for the lifetime of the process.

TypeScript evaluates a default parameter on every call that omits it, so this is a genuine language difference and not a gotcha you can reason your way to from first principles.

The fix, which is the universal idiom
def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

You will see that three-line shape constantly. Now you know it is not defensive noise: it is the only correct way to write a mutable default.

Assignment never copies

Two names, one list
a = [1, 2, 3]
b = a
b.append(4)
a            # [1, 2, 3, 4]

Identical to JavaScript, and worth saying out loud anyway because Python’s lack of const removes the visual cue you are used to. There is no const, no readonly, and no frozen-by-default anything. If you want a copy, ask for one: b = a.copy() or b = a[:] for a shallow one, copy.deepcopy(a) for the recursive one.

The immutable types are str, int, float, bool, tuple, frozenset and bytes. Everything else is mutable and shared.

Where this goes

That is the whole set of Python behaviours that will silently do the wrong thing when you write TypeScript by reflex. The next lesson is objects: self, __init__, the dunder protocols, and why nothing is private.

Practice

Try it yourself

Quiz

An empty list in a condition

You write if results: where results is an empty list. Does the block run?

  1. AYes, but only if the list was created as a literal rather than returned from a function
  2. BYes, because a list is an object and every object reference is truthy
  3. CNo, because an empty list is falsy, and so are an empty dict, an empty string, an empty set and zero
  4. DNo, but only for lists, since the other containers follow the JavaScript rule and stay truthy when empty
Show answer

Correct answer: C — No, because an empty list is falsy, and so are an empty dict, an empty string, an empty set and zero

Empty containers are falsy in Python and truthy in JavaScript, and this is the difference most likely to produce a wrong answer rather than a crash. The every-object-is-truthy option is the JavaScript rule stated correctly, which is what makes it the strongest distractor: it is not nonsense, it is exactly right about a different language. if results: is the idiomatic emptiness check and if results is not None: is what you write when you actually mean not-None.

Quiz

The default that persists

A function is declared def add(item, bucket=[]): and its body appends to bucket and returns it. You call add("a") and then add("b"). What does the second call return?

  1. AIt raises ValueError, because a mutable object is rejected as a default at definition time
  2. B["b"], because each call that omits the argument evaluates the default expression afresh
  3. C["a", "b"], because Python passes the caller's previous return value back in when an argument is omitted
  4. D["a", "b"], because the default list is created once at definition time and shared by every call
Show answer

Correct answer: D — ["a", "b"], because the default list is created once at definition time and shared by every call

The default expression is evaluated ONCE, when the def statement runs, and the resulting object is reused by every call that omits the argument. So one list is shared across the whole program lifetime and accumulates. The fresh-evaluation option is what everyone assumes and is what TypeScript actually does. The previous-return-value option reaches the right answer through a completely invented mechanism, which is why it is worth including: recognising the correct output is not the same as understanding it. The fix is bucket=None plus if bucket is None: bucket = [] in the body.

Quiz

is against ==

When is is the correct operator to reach for rather than ==?

  1. AFor None, True and False, which are singletons, and essentially nowhere else
  2. BWhenever you want strict comparison without coercion, as a direct replacement for ===
  3. CFor any immutable value, since immutability guarantees that equal values share one object
  4. DFor strings and small integers, which the interpreter interns so identity and equality agree
Show answer

Correct answer: A — For None, True and False, which are singletons, and essentially nowhere else

is tests object identity and == tests value. Python's == already does no type coercion, so it is not the loose comparison === was invented to replace, which makes the strict-replacement-for-=== framing a false analogy that leads directly to the interning claim. Small integers and short strings genuinely ARE interned in CPython, so is appears to work on them and then fails on a value that fell outside the cache. That is the exact shape of an intermittent bug: correct on your test data, wrong in production. Reserve is for the three singletons.

Recall

One absence, not two

The mental model to carry, because you are used to a language with two kinds of nothing.

Python has None where TypeScript has both null and undefined. What follows from having only one, and what is the correct way to test for it?

Reveal answer

There is no separate undeclared-versus-declared-as-empty distinction: a name is either bound or it is not, and reading an unbound one is an error rather than undefined. So None always means a value that is deliberately absent, never one that was never set. Test with x is None and x is not None, using identity rather than equality, because None is a singleton and because a class is free to define __eq__ such that x == None returns something surprising. There is also no optional chaining operator: a?.b has no Python spelling and you write an explicit if a is not None or use getattr.

Check

Audit an emptiness check

Find a function in any Python file you have access to that takes an optional collection argument. Read its guard clause.

You should see

You can say whether the guard means empty or means absent, whether those two cases are meant to behave differently, and if the default is a mutable literal you have spotted it.

Sign in to track your progress →