A sole underscore _ is often used in code snippets to drop one or more outputs.

proc = subprocess.Popen(...)
_, errs = proc.communicate()
# do smth with errs

Why do we use _ in place of a variable name in this case? Is it a convention or there is some magic behind? Let’s investigate with dis.

A simple case first.

def f(some_tuple):
    _, b = some_tuple

def g(some_tuple):
    a, b = some_tuple

import dis
dis.dis(f)
dis.dis(g)

Output:

  2           0 LOAD_GLOBAL              0 (some_tuple)
              2 UNPACK_SEQUENCE          2
              4 STORE_FAST               0 (_)   <<< Assigned here
              6 STORE_FAST               1 (b)
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE

vs

  2           0 LOAD_GLOBAL              0 (some_tuple)
              2 UNPACK_SEQUENCE          2
              4 STORE_FAST               0 (a)
              6 STORE_FAST               1 (b)
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE

Seems like no difference: the bytecode is identical and _ is treated as a proper variable name. Let’s check if we can access it.

_, a = "hello", "world"
print(_)

Output:

hello

Good news! Python does not care about the underscore itself and does not treat it specially. This means less special cases, less unseen bugs and less things to remember.

Now, let’s check other contexts: simple context manager first.

def f():
    with context_manager as _:
        pass
dis.dis(f)

Output:

  2           0 LOAD_GLOBAL              0 (context_manager)
              2 SETUP_WITH               9 (to 22)
              4 STORE_FAST               0 (_)  <<< `_` is assigned as
                                                    any other name

  3           6 POP_BLOCK

  2           8 LOAD_CONST               0 (None)
             10 DUP_TOP
             12 DUP_TOP
             14 CALL_FUNCTION            3
             16 POP_TOP
             18 LOAD_CONST               0 (None)
             20 RETURN_VALUE
        >>   22 WITH_EXCEPT_START
             24 POP_JUMP_IF_TRUE        14 (to 28)
             26 RERAISE                  1
        >>   28 POP_TOP
             30 POP_TOP
             32 POP_TOP
             34 POP_EXCEPT
             36 POP_TOP
             38 LOAD_CONST               0 (None)
             40 RETURN_VALUE

Nothing special here: _ is assigned and accessible. Exceptions:

def f():
    try:
        raising_function()
    except Exception as _:
        pass
dis.dis(f)

Output:

  3           0 SETUP_FINALLY            6 (to 14)

  4           2 LOAD_GLOBAL              0 (raising_function)
              4 CALL_FUNCTION            0
              6 POP_TOP
              8 POP_BLOCK
             10 LOAD_CONST               0 (None)
             12 RETURN_VALUE

  5     >>   14 DUP_TOP
             16 LOAD_GLOBAL              1 (Exception)
             18 JUMP_IF_NOT_EXC_MATCH    25 (to 50)
             20 POP_TOP
             22 STORE_FAST               0 (_)  <<< Assigned as well!
             24 POP_TOP
             26 SETUP_FINALLY            7 (to 42)

  6          28 POP_BLOCK
             30 POP_EXCEPT
             32 LOAD_CONST               0 (None)
             34 STORE_FAST               0 (_)  <<< and here
             36 DELETE_FAST              0 (_)
             38 LOAD_CONST               0 (None)
             40 RETURN_VALUE
        >>   42 LOAD_CONST               0 (None)
             44 STORE_FAST               0 (_)  <<< here as well
             46 DELETE_FAST              0 (_)
             48 RERAISE                  1

  5     >>   50 RERAISE                  0

Loops:

def f():
    for _ in iterator:
        pass
dis.dis(f)

Output:

  3           0 LOAD_GLOBAL              0 (iterator)
              2 GET_ITER
        >>    4 FOR_ITER                 2 (to 10)
              6 STORE_FAST               0 (_)   <<< Assigned

  4           8 JUMP_ABSOLUTE            2 (to 4)

  3     >>   10 LOAD_CONST               0 (None)
             12 RETURN_VALUE

All these examples seem to consider _ as any other variable name. But there is one case when _ is treated differently. The match statement introduced in 3.10 clearly discriminates matching against _. Demonstrating this is slightly non-trivial. The following single-case match code is equivalent to assignment whatever = var.

def f():
    match var:
        case whatever:
            pass
dis.dis(f)

Output:

  3           0 LOAD_GLOBAL              0 (var)

  4           2 STORE_FAST               0 (whatever)

  5           4 LOAD_CONST               0 (None)
              6 RETURN_VALUE

That is: load the value in var with LOAD_GLOBAL and store it into whatever with STORE_FAST.

Using _ in place of whatever introduces a small change in the bytecode.

def f():
    match var:
        case _:
            pass
dis.dis(f)

Output:

  3           0 LOAD_GLOBAL              0 (var)

  4           2 POP_TOP

  5           4 LOAD_CONST               0 (None)
              6 RETURN_VALUE

In place of STORE_FAST we now see POP_TOP which simply discards var. The name _ is never assigned and does not exist at all. Thus, the code is equivalent to a single statement var which checks if var exists and ignores otherwise.

Obviously, there is no difference if a throw-away variable name is used once. But the following code will likely raise a NameError:

def f(var):
    match var:
        case _:
            pass
    print(_)
f('hello world')
NameError: name '_' is not defined

While using whatever in place of _ is actually a valid logic.

def f(var):
    match var:
        case whatever:
            pass
    print(whatever)
f('hello world')

Output:

hello world

Why is it designed like this? I have no idea. But I very well imagine this point discussed during job interviews: python 3.10 actually treats the underscore variable in a special way.