To expand on blindriver's response, the standard C-based implementation of Python ("CPython") uses a stack-based assembly language for what's called the "Python virtual machine".

In the PVM, objects on the stack are owned by the stack. The implementation carefully ensures that the reference count of those stack-owned objects is correct. That's how the return object is not freed when the local context finishes - the stack owns it.

You can look at the CPython assembly language with the "dis" module:

def func(a, b):
return a + b

>>> from t import *
>>> dis.dis(func)
2 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 RETURN_VALUE

The "LOAD_FAST" tells the PVM to get the value of the "a" local variable and put it on the stack. More specifically, we can look at the implementation of "LOAD_FAST" at https://github.com/python/cpython/blob/main/Python/generated... (which has is derived from https://github.com/python/cpython/blob/main/Python/bytecodes... )

TARGET(LOAD_FAST_CHECK) {
PyObject *value;
#line 150 "Python/bytecodes.c"
value = GETLOCAL(oparg);
if (value == NULL) goto unbound_local_error;
Py_INCREF(value);
#line 41 "Python/generated_cases.c.h"
STACK_GROW(1);
stack_pointer[-1] = value;
DISPATCH();
}

This gets the local value from "a". If it doesn't exist, raise an UnboundLocalError. If it does exist, increase the reference count, increase the stack size by one, and put the new value on end of the stack. The DISPATCH() means to go to the next instruction.

The BINARY_ADD implementation has a number of specializations. If a and b are both integers, we can see the implementation at https://github.com/python/cpython/blob/main/Python/generated... :

TARGET(BINARY_OP_ADD_INT) {
PyObject *right = stack_pointer[-1];
PyObject *left = stack_pointer[-2];
PyObject *sum;
#line 323 "Python/bytecodes.c"
assert(cframe.use_tracing == 0);
DEOPT_IF(!PyLong_CheckExact(left), BINARY_OP);
DEOPT_IF(Py_TYPE(right) != Py_TYPE(left), BINARY_OP);
STAT_INC(BINARY_OP, hit);
sum = _PyLong_Add((PyLongObject *)left, (PyLongObject *)right);
_Py_DECREF_SPECIALIZED(right, (destructor)PyObject_Free);
_Py_DECREF_SPECIALIZED(left, (destructor)PyObject_Free);
if (sum == NULL) goto pop_2_error;
#line 456 "Python/generated_cases.c.h"
STACK_SHRINK(1);
stack_pointer[-1] = sum;
next_instr += 1;
DISPATCH();
}

This takes the last two elements on the stack (at positions -1 and -2), does some checks that these really are integers, computes the sum, does a call to the internal macro _Py_DECREF_SPECIALIZED (at the top of the file), which decrements the reference count and frees the object if the refcount is 0.

_Py_DECREF_STAT_INC(); \
PyObject *op = _PyObject_CAST(arg); \
if (--op->ob_refcnt == 0) { \
destructor d = (destructor)(dealloc); \
d(op); \
}

It can do this because it knows the object is an integer, so it knows the correct deallocator for it.

The _PyLong_Add returns the sum as an object with a refcount of at least 1, so there's no need for an increment in the PVM implementation.

Since the last two objects have been deallocated, it removes those two from the stack, but it will place the new sum in the stack, giving an overall stack size shrink by one.

The sum is placed in the end of the stack, and execution goes on to the next instruction.