Skip to content

Core Concepts

The handful of fundamentals that everything else in Python builds on - how functions hand back values, how variables point to objects, and which types can be changed.

This is the single most common source of confusion in Python, so it is worth understanding deeply.

return hands a value back to the caller. You can store that value, multiply it, print it, or pass it on to another function.

print() displays something on screen, but hands nothing back (technically it returns None).

# Function A: with return
def double(x):
return x * 2
# Function B: with print
def double_print(x):
print(x * 2)

Both functions look the same when you call them:

double(5) # in the shell you see: 10
double_print(5) # in the shell you see: 10

But the real difference is what you can do afterwards with the result:

a = double(5) # a = 10
b = double_print(5) # prints "10" on screen, but b = None
a + 1 # → 11 (works)
b + 1 # → TypeError: int + NoneType
double(3) + double(4) # → 6 + 8 = 14 (works)
double_print(3) + double_print(4) # prints 6, prints 8, then None + None → TypeError

Common gotcha: A function that uses print() instead of return actually returns None. So “what does this function return?” is None, even though it shows something on screen.

Start the Python REPL with python3 and type:

def f(x):
return x * 2
def g(x):
print(x * 2)
a = f(5)
b = g(5)
print("a =", a)
print("b =", b)
print(type(a))
print(type(b))

Look closely at the difference. Then try 2 * f(3) and 2 * g(3) and see what happens.

With mutable types (list, dict, set), the statement b = a does not make a copy. Both variables point to the same object in memory.

a = [1, 2, 3]
b = a # b points to the SAME list as a
b.append(4)
print(a) # → [1, 2, 3, 4] ← also changed!

Why? Because a and b are like two name tags on the same package. If you change the package through b, then a sees that change too.

This does not apply to immutable types:

a = 5
b = a
b = 10
print(a) # → 5 (unchanged, because int is immutable)

To make a real copy:

b = a.copy() # or: b = a[:] or: b = list(a)
b.append(4)
print(a) # → [1, 2, 3] (unchanged, b is its own copy)
x = [10, 20, 30]
y = x
y[0] = 999
print(x) # What do you expect?
# Now with a copy:
x = [10, 20, 30]
y = x.copy()
y[0] = 999
print(x) # And now?

Memorise this list:

IMMUTABLE (cannot be changed in place):
int, float, bool, str, tuple
MUTABLE (can be changed in place):
list, dict, set

What does “cannot be changed in place” mean?

s = "hello"
s[0] = "H" # → TypeError! Strings are immutable.
s = "Hello" # works → you build a NEW string object and point
# s at it. The old "hello" is discarded.

Consequences:

  • Immutable types: methods always return a copy (s.upper() does not change s).
  • Mutable types: methods change the object itself (lst.sort() changes lst).
  • Sets can only contain immutable elements.

Quick rule: s[0] = 'X' on a string raises a TypeError. lst[0] = 'X' on a list works fine.

Both use {}, but:

my_set = {1, 2, 3} # values only → SET
my_dict = {'a': 1, 'b': 2} # key:value pairs → DICT
empty_dict = {} # empty {} is a DICT, not a set!
empty_set = set() # this is how you make an empty set

len() works on all collections: lists, dicts, sets, strings, and tuples.

Under the hood: mutability is memory mapping

Section titled “Under the hood: mutability is memory mapping”

This goes a level deeper than you need day to day, but it explains why references and mutability behave the way they do. The trick is to stop thinking in terms of Python and start thinking in terms of memory.

Picture memory as a long wall of lockers. Every value your program creates sits in a locker. A variable is not the value - it is just a label holding a locker number. When you write a = [1, 2, 3], Python puts the list in a locker and writes that locker number on the label a.

The difference between mutable and immutable is whether the locker is locked:

  • Immutable types (int, float, bool, str, tuple) live in locked lockers. You can read the contents, but you cannot change them. To get a different value, Python rents a new locker and repoints the label at it. The old box is left behind.
  • Mutable types (list, dict, set) live in open lockers. Anyone holding the locker number can rearrange the contents, and the locker number stays the same.

You can see the locker number with id():

a = [1, 2, 3]
b = a
print(id(a) == id(b)) # True → same locker number
n = 5
print(id(n))
n += 1 # builds a NEW int in a new locker
print(id(n)) # different number → n now points elsewhere

This is also why a string cannot be edited in place. s[0] = 'H' tries to overwrite a locked locker, so you get a TypeError. s = "Hello" is allowed because it does not touch the old locker at all - it just points s at a new one.

The key insight: aliasing is about the locker number, not about Python’s def, indentation, or scope rules. When you write b = a, Python copies the number, not the contents. Both labels now name the same box.

Passing a value into a function is exactly the same move - the parameter is just one more label pointing at the same locker:

a = [1, 2, 3]
def add_item(box): # 'box' is another label for the SAME list
box.append(99)
add_item(a)
print(a) # → [1, 2, 3, 99] changed!

The change “reached” the original list not because of any Python rule about functions, but because box and a held the same locker number and the locker was open. A function boundary does not make a copy - only an explicit a.copy() rents a new locker.

Takeaway: Whether a change is visible somewhere else depends on one question: do the two names share a locker number, and is that locker open? Memory decides, not the layout of your code.