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.
Functions: return vs print()
Section titled “Functions: return vs print()”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 returndef double(x): return x * 2
# Function B: with printdef double_print(x): print(x * 2)Both functions look the same when you call them:
double(5) # in the shell you see: 10double_print(5) # in the shell you see: 10But the real difference is what you can do afterwards with the result:
a = double(5) # a = 10b = 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 → TypeErrorCommon gotcha: A function that uses
print()instead ofreturnactually returnsNone. So “what does this function return?” isNone, even though it shows something on screen.
Try it yourself
Section titled “Try it yourself”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.
Variables and References
Section titled “Variables and References”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 ab.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 = 5b = ab = 10print(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)Try it yourself
Section titled “Try it yourself”x = [10, 20, 30]y = xy[0] = 999print(x) # What do you expect?
# Now with a copy:x = [10, 20, 30]y = x.copy()y[0] = 999print(x) # And now?Mutable vs Immutable
Section titled “Mutable vs Immutable”Memorise this list:
IMMUTABLE (cannot be changed in place): int, float, bool, str, tuple
MUTABLE (can be changed in place): list, dict, setWhat 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 changes). - Mutable types: methods change the object itself (
lst.sort()changeslst). - Sets can only contain immutable elements.
Quick rule:
s[0] = 'X'on a string raises aTypeError.lst[0] = 'X'on a list works fine.
Recognising Sets vs Dicts
Section titled “Recognising Sets vs Dicts”Both use {}, but:
my_set = {1, 2, 3} # values only → SETmy_dict = {'a': 1, 'b': 2} # key:value pairs → DICTempty_dict = {} # empty {} is a DICT, not a set!empty_set = set() # this is how you make an empty setlen() 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 = aprint(id(a) == id(b)) # True → same locker number
n = 5print(id(n))n += 1 # builds a NEW int in a new lockerprint(id(n)) # different number → n now points elsewhereThis 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.
Aliasing follows the memory, not the code
Section titled “Aliasing follows the memory, not the code”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.