Assignment Expressions
The walrus operator (:=), added in Python 3.8, lets you assign a value to a name as part of a larger expression, avoiding repeated computation and awkward restructuring in comprehensions, while loops, and conditionals.
Ordinary assignment (x = value) is a statement in Python — it cannot appear inside an expression like a function call argument or an if condition. Python 3.8 (PEP 572) introduced the assignment expression, written name := value and nicknamed the 'walrus operator' for its := resemblance to a walrus's eyes and tusks, which does the same binding but evaluates to the assigned value, so it can be embedded directly inside a larger expression.
The Problem: Assignment Is a Statement, Not an Expression
Before 3.8, computing a value and testing it required either two lines (compute, then test) or repeating the computation, since = cannot appear inside an if condition, a comprehension, or any other expression context.
# without walrus: must compute, then check, in two separate steps
data = fetch_data()
if data is not None:
process(data)
# or, worse, computing twice
if fetch_data() is not None:
process(fetch_data()) # calls fetch_data() again -- wasteful, maybe wrongBasic Syntax: The Walrus Operator
name := expression binds expression's value to name AND evaluates, as a whole, to that same value — letting you assign and use the result in one place.
The parentheses around data := fetch_data() are required by Python's grammar in most contexts (an if statement's condition, for instance) to disambiguate the walrus from a plain assignment statement or a keyword argument; get in the habit of parenthesizing assignment expressions.
if (data := fetch_data()) is not None:
process(data) # data is now available here too, no second call needed
print(data) # still accessible after the if block -- see scope rules belowAvoiding Repeated Computation in Conditionals
This is the walrus's most common, most defensible use: a value needed both for a condition check and inside the block that condition guards, computed exactly once.
import re
text = "order #48213 shipped"
if (match := re.search(r"#(\d+)", text)) is not None:
print(f"found order number: {match.group(1)}")
else:
print("no order number found")Assignment Expressions in while Loops
A classic pattern this cleans up considerably: 'read a chunk, check if it's empty, process it, repeat' loops, which previously needed the read duplicated before the loop and again at the end of the loop body.
# before walrus: the read call is duplicated
chunk = file.read(1024)
while chunk:
process(chunk)
chunk = file.read(1024)
# with walrus: read happens in exactly one place
while (chunk := file.read(1024)):
process(chunk)Assignment Expressions in Comprehensions
Inside a list/set/dict comprehension or generator expression, the walrus lets you reuse an expensive computed value across both the filter condition and the output expression, instead of computing it twice.
values = [1, 4, 9, 16, 25, 36]
# before: expensive_check(v) computed twice per surviving element
results = [expensive_check(v) for v in values if expensive_check(v) is not None]
# with walrus: computed exactly once per element
results = [y for v in values if (y := expensive_check(v)) is not None]Scope Rules: Where the Bound Name Lives
This is the walrus's most important and most frequently misunderstood rule: a name bound by := inside a comprehension binds in the enclosing scope (the function or module containing the comprehension), NOT inside the comprehension's own implicit scope, unlike the comprehension's own loop variable (for v in ...), which stays local to the comprehension as usual.
This asymmetry is deliberate: the whole point of allowing := inside a comprehension is often to expose a value computed there to code after the comprehension (as in the re.search-style pattern), so PEP 572 makes the walrus target bind to the nearest enclosing function/module scope rather than the comprehension's own scope.
def find_first_match(items, predicate):
matches = [item for item in items if (found := predicate(item))]
# 'found' leaked out of the comprehension into find_first_match's scope
print(f"last checked value inside the comprehension: {found}")
return matches[0] if matches else None
# 'item', the ordinary for-loop variable, does NOT leak the same way --
# it stays scoped to the comprehension, exactly as it always hasRestrictions on Where := Can Be Used
A handful of syntactic positions deliberately disallow bare assignment expressions, specifically to avoid ambiguity with existing syntax.
Disallowed (SyntaxError) Reason
--------------------------------------- -----------------------------------
x := 5 (as a top-level ambiguous with a plain assignment
statement, unparenthesized) statement; use x = 5 instead
f(x = 5) = here means a keyword argument,
not assignment -- use f(x := 5)
if a walrus is truly intended
[y := f(x) for x in data] actually fine -- but chained/
nested walrus targets can get
confusing; keep it to one per
clause for readability# x := 5 # SyntaxError at the top level -- not a statement
x = 5 # fine -- plain assignment statement
print(x := 5) # fine -- := used inside an expression context (a call argument)Readability: When to Use It and When Not To
The walrus exists to eliminate real duplication or awkward restructuring — not as a way to compress every two-line snippet into one. PEP 572 and the wider Python community are explicit that overusing := to cram multiple assignments into a single dense line hurts readability more than it helps.
Good candidates: a value used in both a condition and the block/expression it guards, loop patterns like 'read/check/process', filtering plus transforming in one comprehension pass. Poor candidates: assignment expressions purely to save a line with no real duplication avoided, or ones that make the surrounding expression harder, not easier, to read at a glance.
# reasonable: eliminates real duplication, reads naturally
if (n := len(data)) > 10:
print(f"{n} items, trimming to 10")
data = data[:10]
# overused: crams unrelated logic into one unreadable line -- avoid this
result = [(y := f(x), z := g(y), y + z) for x in data] # hard to followComplete Example
A small end-to-end example combining several idiomatic walrus uses: reading paginated results until a page comes back empty, and filtering plus transforming in the same comprehension pass.
def fetch_page(page_number: int) -> list[dict]:
... # returns [] once there are no more pages
all_items = []
page_number = 1
while (page := fetch_page(page_number)):
all_items.extend(page)
page_number += 1
# filter to items with a valid score, and keep just that score,
# computing the (possibly expensive) score lookup exactly once each
scored = [score for item in all_items if (score := item.get("score")) is not None]
print(f"collected {len(all_items)} items, {len(scored)} with a valid score")Quick Reference
name := expr -> binds name to expr's value; evaluates to that value
if (n := f()) > 0: ... -> use n both in the condition and the block
while (x := next_chunk()): ... -> read-check-process loop, one read site
[y for v in data if (y := g(v))] -> compute once, filter and use the result
Scope: a walrus target inside a comprehension binds in the ENCLOSING
scope, not the comprehension's own scope (unlike 'for v in ...')
Disallowed unparenthesized at statement level, and as f(x = 5) which
means a keyword argument insteadKey Takeaways
The walrus operator (:=), added in Python 3.8 (PEP 572), lets an assignment appear as part of a larger expression rather than only as its own statement.
name := expr both binds name and evaluates to expr's value, letting one computation be assigned and used in the same place — most valuably inside conditionals, while-loop headers, and comprehensions.
A name assigned via := inside a comprehension leaks into the enclosing function/module scope, unlike the comprehension's own for-loop variable, which stays local as always.
Assignment expressions are disallowed as bare top-level statements and inside keyword-argument position (f(x = 5) means something else entirely); parenthesize := in most real usage.
Use := to eliminate genuine duplicated computation or awkward restructuring — not to compress ordinary two-line logic into a denser, harder-to-read single line.
if match:= re.search(pattern, text): print(match.group()) # in some contexts this needs parentheses to parse as intended if (match:= re.search(pattern, text)): print(match.group())
While Python's grammar happens to allow this specific unparenthesized form directly after 'if', many other positions require parentheses around an assignment expression to disambiguate it from other syntax; parenthesizing consistently avoids surprises and reads more clearly regardless.
def some_function(x = 5): # this is a DEFAULT ARGUMENT, not a walrus return x some_function(x = 10) # this is a KEYWORD ARGUMENT, not a walrus either def some_function(x=5): return x result = some_function(10) if (y:= result) > 5: # a genuine assignment expression, in expression context print(y)
= in a function signature or a call's keyword-argument position always means a default value or keyword argument, never an assignment expression; := (with the colon) is the only spelling that creates an assignment expression, and it must appear where an expression is syntactically valid.
def process(x): a = f(x) b = g(a) return a, b, a * b data = [process(x) for x in items]
Assignment expressions are meant to eliminate real duplication or awkward restructuring in a single, focused spot -- chaining several unrelated := assignments in one comprehension trades readability for a shorter line; prefer a small helper function when several steps are genuinely involved.
What does the walrus operator (:=) do differently from a plain assignment statement (=)?
A plain assignment (x = value) is a statement and cannot appear inside a larger expression (like an if condition or a function argument); an assignment expression (x := value) both binds the name and evaluates, as an expression, to the assigned value, so it can be embedded directly inside conditionals, while-loop headers, comprehensions, and other expression contexts.
Rewrite this without a walrus operator, without calling fetch() twice: if (value:= fetch()) is not None: use(value) value = fetch() if value is not None: use(value) (This is exactly the two-statement pattern the walrus operator was introduced to let you collapse into one expression when useful.)
Given matches = [y for v in values if (y := transform(v)) is not None], where does the name y live after the comprehension finishes executing, and how is that different from where v lives?
y is bound in the enclosing function/module scope that contains the comprehension (it 'leaks out' and remains accessible after the comprehension line), while v, the comprehension's own for-loop variable, stays scoped strictly to the comprehension itself and is not accessible afterward -- this asymmetry is a deliberate, documented rule of assignment expressions in comprehensions.
Why does f(x = 5) not create an assignment expression, even though it superficially resembles one?
A bare = inside a function call's argument list always means a keyword argument, never an assignment; to actually use an assignment expression as a call argument you must use the := spelling, e.g. f(x := 5), and typically wrap it in parentheses since keyword-argument syntax otherwise takes priority.
In what Python version was the assignment expression introduced, and under what PEP?
Python 3.8, under PEP 572.
Give an example of a case where using := would hurt readability rather than help it, and explain why.
Chaining several unrelated assignments into one comprehension or expression, such as [(a := f(x), b := g(a), a * b) for x in items], crams multiple sequential computation steps into a single dense line that is hard to read and debug; a small helper function with ordinary sequential assignment statements communicates the same logic far more clearly. The walrus is best reserved for eliminating a single genuine duplicated computation, not for compressing multi-step logic.
Write a function using the walrus operator inside a while loop that reads lines from a list-based 'stream' (a function next_line() returning None when exhausted) and collects non-empty lines into a list, stopping at the first None.
def make_stream(lines): it = iter(lines) def next_line(): return next(it, None) return next_line def collect_lines(next_line): collected = [] while (line := next_line()) is not None: if line: collected.append(line) return collected stream = make_stream(["first", "", "second", None, "unreachable"]) print(collect_lines(stream)) # ['first', 'second']
The walrus operator inside the while condition calls next_line() exactly once per iteration, binds the result to line, and the loop keeps running as long as line is not None; empty strings are skipped by the inner if but don't stop the loop, since only None (not falsy-but-present values) ends iteration.
How many times does 'called' print, what does results contain, and does the final print(y) succeed or raise an error? Why?
'called' prints 3 times (once per element of range(3): f(0)=0, f(1)=2, f(2)=4). results is [2, 4] (only y > 0 survives the filter). print(y) succeeds and prints 4 -- the walrus target y leaks into the enclosing scope where the comprehension is defined, and retains the value from the comprehension's last evaluation (x=2), unlike the comprehension's own loop variable x, which would raise NameError if referenced afterward.
Write a function that uses an assignment expression inside a list comprehension to both filter a list of numbers to only those whose square exceeds 50, and collect the computed squares (not the original numbers), computing each square only once.
def large_squares(numbers: list[int]) -> list[int]: return [sq for n in numbers if (sq := n * n) > 50] print(large_squares([3, 6, 8, 1, 9])) # [64, 81] (6*6=36 excluded, 8*8=64 and 9*9=81 included)
Inside the comprehension, (sq := n * n) computes each number's square exactly once, binds it to sq, and the > 50 filter test reuses that same computed value; because the output expression is sq (not n), the comprehension collects the squares themselves rather than the original qualifying numbers, all without a second, duplicate multiplication.
def f(x):
print("called")
return x * 2
results = [y for x in range(3) if (y := f(x)) > 0]
print(results)
print(y)