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 wrong

Basic 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 below

Avoiding 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 has

Restrictions 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 follow

Complete 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 instead

Key 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)