None, Truthiness, and Initialization
None, Truthiness, and Initialization
None means “not set.” False, 0, and [] are set — they just happen to be empty or no. The boring default is to test for the value you mean, not for “looks false in an if.”
Mental model
Python has a small set of values that are falsy in a boolean context: None, False, 0, 0.0, "", [], {}, set(), and a few others. Everything else is truthy, including "0", "False", and [0].
That list is a convenience for if items: meaning “is there anything here?” It is a trap when 0 is a legal table number, when [] is a legal empty note list, or when None means “the cook has not answered yet.”
Initialization is how a function gets a starting container. A default argument is evaluated once, when the def line runs, not each call. A mutable default ([], {}) is shared across calls. That is the classic Python footgun.
Worked examples
Case 1: Four different “no”s
Save as four_nos.py. A missing close time, an unpaid check, a zero-cover walk-in, and an empty note list are not the same fact.
# four_nos.py
def describe(ticket):
if ticket["closed_at"] is None:
print("still open")
if ticket["paid"] is False:
print("not paid")
if ticket["covers"] == 0:
print("walk-in, covers unknown")
if ticket["notes"] == []:
print("no notes yet")
def main():
ticket = {
"closed_at": None,
"paid": False,
"covers": 0,
"notes": [],
}
describe(ticket)
print("bool(None)", bool(None))
print("bool(False)", bool(False))
print("bool(0)", bool(0))
print("bool([])", bool([]))
print("None == False", None == False)
print("None == 0", None == 0)
print("False == 0", False == 0)
if __name__ == "__main__":
main()Run:
uv run python four_nos.pyOutput:
still open
not paid
walk-in, covers unknown
no notes yet
bool(None) False
bool(False) False
bool(0) False
bool([]) False
None == False False
None == 0 False
False == 0 True
All four are falsy. Only False == 0 is actually equal, because bool subclasses int. None equals neither. Test is None, is False, == 0, and == [] when those meanings matter.
Case 2: if notes: is fine for “any notes”
When empty really means “skip,” a truthiness check is the boring line.
# any_notes.py
def print_notes(notes):
if notes:
print("notes:", ", ".join(notes))
else:
print("no notes")
def main():
print_notes(["window", "allergy"])
print_notes([])
if __name__ == "__main__":
main()Run:
uv run python any_notes.pyOutput:
notes: window, allergy
no notes
That is the intended use. Do not use if notes: when notes might be None (not loaded) versus [] (loaded, empty). Those two states need different handling.
Case 3: Default None, then make a list
When a function should start with an empty list unless the caller passes one, default to None and create the list inside.
# add_note.py
def add_note(note, notes=None):
if notes is None:
notes = []
notes.append(note)
return notes
def main():
a = add_note("window")
b = add_note("allergy")
shared = []
add_note("booth", shared)
add_note("quiet", shared)
print(a)
print(b)
print(shared)
if __name__ == "__main__":
main()Run:
uv run python add_note.pyOutput:
['window']
['allergy']
['booth', 'quiet']
Each call that omits notes gets a new list. The caller who passes shared keeps one list on purpose.
The trap
A mutable default argument is evaluated once. Every call that omits the argument appends to the same list.
# sticky_notes.py
def add_note(note, notes=[]):
notes.append(note)
return notes
def main():
first = add_note("window")
second = add_note("allergy")
print("first ", first)
print("second", second)
print("same object", first is second)
if __name__ == "__main__":
main()Run:
uv run python sticky_notes.pyOutput:
first ['window', 'allergy']
second ['window', 'allergy']
same object True
first and second are one list. The second ticket inherited the first ticket’s note. The fix is Case 3: default None, assign notes = [] inside the function.
The same trap hits def label(ticket, extra={}): — one dict, shared forever. Default to None for every mutable.
The boring rule
Nonemeans missing. Test withis None/is not None.0,[],"", andFalseare real values. Test them by name when0or empty is legal.if items:is fine when you only care “any vs none.”- Never default an argument to
[]or{}. UseNoneand create inside. - Do not write
if not ticket["closed_at"]ifclosed_atcould beNoneor0for different reasons.
Try this
- Change
four_nos.pysocoversisNone(unknown) versus0(walk-in). Print two different sentences. - In
add_note.py, add a third calladd_note("vip")and confirm you still get a fresh list. - Copy
sticky_notes.pyand defaultnotes=Noneinstead. Re-run.first is secondshould beFalse.