Emmanuel Akinfulubi

Starting out all 3 written

What Python actually is, what happens when you run a file, and how to read the error you are certainly about to get.

1 What Python actually is, and what runs your code

Python is a language: a set of rules about what text means. CPython is a program that reads text following those rules and does what it says. Nearly every confusing thing you will read about Python — why it is "slow", what __pycache__ is, what the GIL even is — is really a fact about that program rather than about the language, and separating the two now saves a lot of confusion later.

Which Python is running this

There is more than one program that can run Python. CPython is the reference implementation and the one almost everyone means; PyPy, GraalPy and MicroPython are others. The interpreter can tell you which one it is, and that is worth knowing how to ask, because a surprising amount of advice on the internet is really advice about one implementation.

Asking the interpreter what it is

ran at build time
import sys

# The version, as something you can compare rather than a string you have to parse.
print("version:       ", sys.version_info)
print("implementation:", sys.implementation.name)

# Feature checks are written against this tuple, and comparing tuples does the
# sensible thing: (3, 14) > (3, 9) because 14 > 9.
print()
print("at least 3.10 (match statements)? ", sys.version_info >= (3, 10))
print("at least 3.12 (error suggestions)?", sys.version_info >= (3, 12))

test("this course is built on CPython", sys.implementation.name, "cpython")
test("the major version is 3", sys.version_info.major, 3)
test("comparing version tuples works the obvious way", (3, 14) > (3, 9), True)

What it printed, when the build ran it

version:        sys.version_info(major=3, minor=14, micro=7, releaselevel='final', serial=0)
implementation: cpython

at least 3.10 (match statements)?  True
at least 3.12 (error suggestions)? True
ok   this course is built on CPython
ok   the major version is 3
ok   comparing version tuples works the obvious way

Every output block in this course was produced by running the panel above it with the interpreter named in that first line. That is the whole arrangement: nothing here is a remembered result, and if a panel stopped behaving the way the page claims, the build that writes these pages would fail rather than publish it.

"Interpreted" does not mean there is no compile step

Python is usually called an interpreted language, and that phrasing suggests the interpreter reads your source line by line and acts on it directly. It does not. Your source is first compiled into bytecode — a compact set of instructions for a machine that does not physically exist — and the interpreter then executes that.

This is not a detail you have to take on trust. compile() is an ordinary built-in function that performs exactly that step and hands you the result as an object you can inspect.

The compile step, made visible

ran at build time
# A string of Python source, as text. Nothing has run yet.
source = "total = 2 + 3\nprint('the code object ran and got', total)"

# Compile it. This is the step people mean when they say Python is "interpreted"
# and imagine does not happen.
code = compile(source, "typed-by-hand", "exec")

print("what compile() returned:", type(code).__name__)
print("it knows where it came from:", code.co_filename)
print("and it carries actual bytecode:", len(code.co_code), "bytes of it")
print()

# Now run the compiled object. The source string is not consulted again.
exec(code)

test("compiling source gives back a code object", type(code).__name__, "code")
test("a code object holds bytes, not text", isinstance(code.co_code, bytes), True)

What it printed, when the build ran it

what compile() returned: code
it knows where it came from: typed-by-hand
and it carries actual bytecode: 28 bytes of it

the code object ran and got 5
ok   compiling source gives back a code object
ok   a code object holds bytes, not text

This is what __pycache__ is for. When you import a module, Python does that compile step and then saves the resulting bytecode next to the source in a __pycache__ directory, so the next import can skip it. That is all it is: a cache. Deleting it is always safe, it is never something you edit, and it belongs in .gitignore. Scripts you run directly are not cached this way, which is why you see __pycache__ appear for your modules and not for the file you ran.

Everything is an object, including the things that do not look like it

In Python there is no separate category of "primitive" values sitting outside the object system. A number is an object. A function is an object. A class is an object. This sounds like trivia and is not: it is why you can put a function in a list, pass a class as an argument, and attach an attribute to almost anything — and it is the reason decorators (lesson 36) work at all.

Ten values, one question: what type is this

ran at build time
# Ten different things, and the one question asked of all of them.
things = [42, 3.5, "hi", [1], (1,), {1}, {"a": 1}, None, print, int]

for thing in things:
    print(f"{repr(thing):>28}  is a  {type(thing).__name__}")

print()
# A function is a value: it can be handed around like any other.
shout = print
shout("a name bound to print still prints")

# And a type is itself an object, whose type is 'type'.
print("the type of int is:", type(int).__name__)

test("int is an object, and an instance of type", isinstance(int, type), True)
test("even None has a type", type(None).__name__, "NoneType")
test("a function can be bound to another name", shout is print, True)

What it printed, when the build ran it

                          42  is a  int
                         3.5  is a  float
                        'hi'  is a  str
                         [1]  is a  list
                        (1,)  is a  tuple
                         {1}  is a  set
                    {'a': 1}  is a  dict
                        None  is a  NoneType
   <built-in function print>  is a  builtin_function_or_method
               <class 'int'>  is a  type

a name bound to print still prints
the type of int is: type
ok   int is an object, and an instance of type
ok   even None has a type
ok   a function can be bound to another name

Running Python, in the two ways you will actually use

Typing python3 with no arguments starts the interactive interpreter — the >>> prompt — which evaluates each line as you type it and prints the result without being asked. It is the fastest way to answer "what does this actually do", and the Playground page shows a session.

For anything you want to keep, put it in a file ending .py and run python3 yourfile.py. The difference that catches people: at the prompt, an expression on its own line shows its value, and in a file it does not. A file that just says 2 + 3 computes five and discards it, printing nothing. In a file you have to ask for output with print.

Why a file needs print() and the prompt does not

ran at build time
# In a file, an expression on its own line is evaluated and thrown away.
2 + 3

# Nothing was printed above. To see a value, ask for it:
print(2 + 3)

# print() can take several values and puts a space between them.
name = "Ada"
print("hello", name, "you are", 36, "years old")

# It returns None -- it is used for its effect, not its value.
returned = print("this line printed, and print handed back:")
print(returned)

test("print returns nothing at all", returned, None)

What it printed, when the build ran it

5
hello Ada you are 36 years old
this line printed, and print handed back:
None
ok   print returns nothing at all

The one piece of setup advice worth giving now. Do not install packages with a bare pip install into whatever Python happens to be first on your PATH. It works right up until two projects want different versions of the same package, and then it fails in a way that is genuinely unpleasant to untangle. Lesson 21 covers virtual environments, which is the fix, and it is worth reading before you install your first package rather than after.

What to take from this lesson

  • The language and the program that runs it are different things. Most "Python is X" claims are really about CPython.
  • Your source is compiled to bytecode first, then executed. __pycache__ is where that bytecode is cached, and it is safe to delete.
  • Everything is an object, which is why functions and classes can be passed around like any other value.
  • In a file, values are not printed unless you print them.

2 Values, names, and what assignment really does

Most courses describe a variable as a box you put a value into. It is a reasonable first picture and it will mislead you within a week. In Python a name is not a container; it is a label attached to an object that exists independently of it. Almost every "why did my list change on its own" question has this as its answer.

Assignment binds a name; it does not copy

b = a does not make a second list. It makes a second name for the one list that already exists. Both labels are attached to the same object, so a change made through either one is visible through the other — because there is only one object to change.

Two names, one list

ran at build time
a = [1, 2]
b = a          # a second name for the SAME list, not a copy
c = [1, 2]     # a genuinely different list that happens to look the same

print("a is b (same object?):", a is b)
print("a is c (same object?):", a is c)
print("a == c (same value?): ", a == c)

# Change the list through b, and look at a.
b.append(3)
print()
print("after b.append(3)")
print("  a:", a)
print("  b:", b)
print("  c:", c, "<- untouched, because it was always a different object")

test("a and b are two names for one object", a is b, True)
test("so appending through b is visible through a", a, [1, 2, 3])
test("c was a separate object all along", c, [1, 2])

What it printed, when the build ran it

a is b (same object?): True
a is c (same object?): False
a == c (same value?):  True

after b.append(3)
  a: [1, 2, 3]
  b: [1, 2, 3]
  c: [1, 2] <- untouched, because it was always a different object
ok   a and b are two names for one object
ok   so appending through b is visible through a
ok   c was a separate object all along

The useful mental picture. Objects float in memory. Names are labels you stick onto them. = moves a label; it never duplicates what the label is stuck to. Ask "how many objects exist here?" rather than "what is in this variable?", and this class of bug stops happening.

Why numbers and strings seem to behave differently

Do the same experiment with integers and nothing alarming happens, which is exactly why the box model survives long enough to hurt you. The reason is not that numbers are copied. It is that numbers are immutable: there is no operation that changes the number 1 into something else, so x += 1 cannot modify an object in place. It builds a new number and re-points the label.

The same syntax, two different things, decided by mutability

ran at build time
x = 1
y = x
x += 1          # NOT a modification -- this binds x to a brand new object

print("x:", x, " y:", y, "  <- y did not follow, because nothing was mutated")

# The same thing with a list, where += DOES mutate in place.
p = [1]
q = p
p += [2]        # for a list, += is in-place extension

print("p:", p, " q:", q, "  <- q followed, because the one list changed")

# Proving the difference rather than asserting it: identity before and after.
n = [1]
before = id(n)
n += [2]
print()
print("list  += kept the same object:", id(n) == before)

m = 1
before_m = id(m)
m += 1
print("int   += made a new object:   ", id(m) != before_m)

test("rebinding x left y alone", (x, y), (2, 1))
test("mutating p was visible through q", (p, q), ([1, 2], [1, 2]))
test("+= on a list mutates in place", id(n) == before, True)

What it printed, when the build ran it

x: 2  y: 1   <- y did not follow, because nothing was mutated
p: [1, 2]  q: [1, 2]   <- q followed, because the one list changed

list  += kept the same object: True
int   += made a new object:    True
ok   rebinding x left y alone
ok   mutating p was visible through q
ok   += on a list mutates in place

This is the one asymmetry to hold on to. For an immutable object (int, float, str, tuple, frozenset, bool, None) += can only rebind, so nothing elsewhere is affected. For a mutable object (list, dict, set, and most classes you write) += changes the object itself, and every other name pointing at it sees the change. Lesson 6 shows how to make a real copy when that is what you wanted.

is and == ask genuinely different questions

== asks "do these have the same value", and a type decides what that means. is asks "are these the very same object", which nothing can fake. Beginners reach for is because it reads like English, and it then appears to work for a while, which is the worst possible outcome.

Equal in value, different objects

ran at build time
# Built at runtime, so the compiler cannot fold them into one constant.
a = int("1000")
b = int("1000")

print("a == b:", a == b, " <- same value")
print("a is b:", a is b, " <- different objects")

s1 = "hello world"
s2 = " ".join(["hello", "world"])
print()
print("s1 == s2:", s1 == s2)
print("s1 is s2:", s1 is s2)

test("equal values", a == b, True)
test("but not the same object", a is b, False)
test("same for the strings", (s1 == s2, s1 is s2), (True, False))

What it printed, when the build ran it

a == b: True  <- same value
a is b: False  <- different objects

s1 == s2: True
s1 is s2: False
ok   equal values
ok   but not the same object
ok   same for the strings

Now the part that makes this genuinely treacherous. Write the same values as literals instead of building them, and is starts returning True — because CPython notices two identical constants in one piece of code and stores only one of them. Nothing about your program changed. The optimiser did.

The same comparison, answered differently by an optimisation you do not control

ran at build time
# Literals, in one code object. The compiler folds these into a single constant.
a = 1000
b = 1000
print("two identical literals, a is b:", a is b)

s1 = "hello world"
s2 = "hello world"
print("two identical strings, s1 is s2:", s1 is s2)

# Which means an 'is' comparison can pass here and fail on the same values
# assembled a different way, or on a different Python. It is not a value test.
built = "hello" + " " + "world"
joined = " ".join(["hello", "world"])
print()
print("concatenated literals:", s1 is built, " <- folded at compile time")
print("joined at runtime:    ", s1 is joined, " <- not folded")

test("all of these are equal in value", s1 == s2 == built == joined, True)
test("identity, though, is inconsistent between them", (s1 is built, s1 is joined), (True, False))

What it printed, when the build ran it

two identical literals, a is b: True
two identical strings, s1 is s2: True

concatenated literals: True  <- folded at compile time
joined at runtime:     False  <- not folded
ok   all of these are equal in value
ok   identity, though, is inconsistent between them

The rule, and it has no exceptions worth learning. Use == to compare values. Use is only to compare against the singletons None, True and False — where there genuinely is only ever one object — and write if x is None rather than if x == None. If you have read the old advice about small integers being cached, notice that the panel above contradicts the usual version of it. That is the real lesson: whether is happens to work on values is an implementation detail, so do not build on it.

Names have to exist before you use them

There is no declaration step, so a name comes into existence at the moment it is first assigned. Using one before that point is not an empty value or a null — it is an error, and a specific one worth recognising immediately.

A name that was never bound

raises NameError
total = 10
print(totl)

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 2, in <module>
    print(totl)
          ^^^^
NameError: name 'totl' is not defined. Did you mean: 'total'?

Read the last line first: NameError, and the name it could not find. Since 3.12 the interpreter also offers the closest name it does know, which here is exactly the one intended. The traceback catalogue collects this and every other error you are likely to meet, each with the real output.

Deleting a name does not necessarily delete the object

del removes a label, not the thing it was attached to. The object survives as long as some other name still refers to it, which is the whole of Python's memory model in one sentence and is covered properly in lesson 45.

del removes a label

ran at build time
data = [1, 2, 3]
alias = data         # two labels, one list

del data             # removes the label 'data', not the list

print("the list is still here, via the other name:", alias)
print("is 'data' still a name?", "data" in dir())

test("the object outlived the deleted name", alias, [1, 2, 3])

What it printed, when the build ran it

the list is still here, via the other name: [1, 2, 3]
is 'data' still a name? False
ok   the object outlived the deleted name

What to take from this lesson

  • A name is a label on an object, not a box holding a value. = moves a label and never copies.
  • Mutable objects can be changed through any name pointing at them. Immutable ones cannot be changed at all, so operations on them rebind instead.
  • == compares values, is compares identity. Use is only for None, True and False.
  • Whether is "works" on ordinary values depends on optimisations you do not control, which is precisely why it is not a value test.

3 Errors are the normal case: reading a traceback

You are going to see far more tracebacks than you see working output, and that is true for the rest of your career rather than just this week. A traceback is not a punishment; it is the most precise description of a problem you will get from any tool, and it has a fixed shape. Learning that shape is probably the highest-value hour in this whole course.

Read it from the bottom

This is the single most important habit, and almost everyone starts with the opposite one. The last line is the exception type and the message — the actual problem. The frames above it are the route the program took to get there, most recent last. Beginners read from the top, land in code they did not write, and conclude the library is broken.

Three frames, one problem

raises ValueError
def load_config(raw):
    return parse_port(raw["port"])

def parse_port(text):
    return int(text)

settings = {"port": "eight thousand"}
load_config(settings)

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 8, in <module>
    load_config(settings)
    ~~~~~~~~~~~^^^^^^^^^^
  File "example.py", line 2, in load_config
    return parse_port(raw["port"])
  File "example.py", line 5, in parse_port
    return int(text)
ValueError: invalid literal for int() with base 10: 'eight thousand'

Read that bottom-up and it tells a complete story:

  • ValueError: invalid literal for int() with base 10: 'eight thousand' — the problem. Something asked int() to convert text that does not spell a number, and it even quotes the offending text back at you.
  • line 5, in parse_port — where it broke. The innermost frame, printed last, which is why the bottom of the stack listing is the most useful part.
  • line 2, in load_config and line 8, in <module> — how it got there. <module> means the top level of the file rather than inside any function.

Notice what that gives you for free: the bad value came in as settings["port"], eight lines away from the function that failed. The traceback is what connects those two places.

A three-step routine that works on any traceback. First, read the last line and take the type and message literally — they are unusually precise. Second, find the lowest frame that is your code; if the bottom frames are inside a library, the mistake is almost always in the last line you wrote, not in the library. Third, only then walk upwards to see which call supplied the bad input.

The carets point at the exact part that failed

Since 3.11 Python underlines the specific sub-expression that raised, not just the line. On a line doing several things at once, this is the difference between knowing which operation broke and guessing.

Two lookups on one line, and only one of them wrong

raises KeyError
prices = {"apple": 100}
quantities = {"apple": 3}

total = prices["apple"] * quantities["pear"]

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 4, in <module>
    total = prices["apple"] * quantities["pear"]
                              ~~~~~~~~~~^^^^^^^^
KeyError: 'pear'

Both halves of that line are dictionary lookups that could have failed. The ~~~~^^^^ markers say it was the second one, and KeyError: 'pear' says which key. Without the markers you would have to work that out yourself.

An error before anything runs looks different

If the file cannot be parsed, none of it executes — so there is no call stack to print, and therefore no Traceback (most recent call last) header at all. Spotting that absence tells you immediately that the problem is the shape of your code rather than the data it was given.

No traceback header, because nothing ran

raises SyntaxError
def greet(name)
    return "hi " + name

The traceback it really produced, when the build ran it

  File "example.py", line 1
    def greet(name)
                   ^
SyntaxError: expected ':'

The practical tell. If you add a print("got here") to debug and it does not appear, and the error has no Traceback header, stop looking at your logic: the file never ran. It is a syntax or indentation problem, and the reported line is where Python gave up rather than necessarily where you typed the mistake — a missing closing bracket is often reported a line or two below the line that opened it.

Two exceptions in one traceback, and the sentence between them

When something goes wrong while handling something else, Python shows both, joined by a sentence. That sentence is not filler — there are two different ones and they mean different things.

The first case: a bug in the error handler itself. The connector reads "During handling of the above exception, another exception occurred", which means the second error was an accident.

An accident inside the except block

raises NameError
def parse(text):
    try:
        return int(text)
    except ValueError:
        return undefined_helper(text)

parse("abc")

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 3, in parse
    return int(text)
ValueError: invalid literal for int() with base 10: 'abc'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "example.py", line 7, in <module>
    parse("abc")
    ~~~~~^^^^^^^
  File "example.py", line 5, in parse
    return undefined_helper(text)
           ^^^^^^^^^^^^^^^^
NameError: name 'undefined_helper' is not defined

The second case: you deliberately translated a low-level failure into a meaningful one using raise ... from. The connector reads "The above exception was the direct cause of the following exception" — on purpose, and preserving the original for whoever debugs it.

A deliberate translation, with the cause kept

raises ConfigError
class ConfigError(Exception):
    pass

def read_port(raw):
    try:
        return int(raw["port"])
    except ValueError as exc:
        raise ConfigError("port must be a number") from exc

read_port({"port": "eight thousand"})

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 6, in read_port
    return int(raw["port"])
ValueError: invalid literal for int() with base 10: 'eight thousand'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "example.py", line 10, in <module>
    read_port({"port": "eight thousand"})
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "example.py", line 8, in read_port
    raise ConfigError("port must be a number") from exc
ConfigError: port must be a number

Which sentence you see tells you where to look. "During handling" usually means a second bug hiding inside an except block, and the lower traceback is the one to fix first. "Direct cause" means the author chose to re-raise, so the upper traceback is the real root and the lower one is the friendlier wrapper around it. Lesson 23 covers writing that second kind deliberately.

A traceback does not have to end the program

Everything above shows an exception reaching the top and stopping the process. That is the default, not the rule: you can catch an exception, keep the traceback as text for a log, and carry on. The traceback module is in the standard library for exactly this.

Catching an error and keeping the traceback for a log

ran at build time
import traceback

def risky():
    return 1 / 0

try:
    risky()
except ZeroDivisionError:
    report = traceback.format_exc()

# Nothing crashed. The traceback is just a string now.
lines = report.strip().split("\n")
print("captured", len(lines), "lines; the last one is:")
print("   ", lines[-1])
print()
print("and the program carried on to print this")

test("the traceback was captured as text", isinstance(report, str), True)
test("it names the exception type", "ZeroDivisionError" in report, True)
test("it identifies the function that failed", "risky" in report, True)

What it printed, when the build ran it

captured 8 lines; the last one is:
    ZeroDivisionError: division by zero

and the program carried on to print this
ok   the traceback was captured as text
ok   it names the exception type
ok   it identifies the function that failed

This is how real programs behave: a web server that returned a blank page on every error would be useless, so it catches the exception, logs the traceback, and returns a 500. The traceback still exists — it went to the log instead of to the user. Lesson 22 covers the try/except machinery properly.

Where to go when you meet one you do not recognise

This course has a traceback catalogue: every common Python error, the code that produces it, what the message actually means, and the fix. Every traceback on that page was captured by running the snippet above it during the build, so the wording matches what your own interpreter will say rather than what some older version used to.

What to take from this lesson

  • Read the last line first: it is the type and the message, and it is the actual problem.
  • Then read the lowest frame that is your code. A traceback ending inside a library usually still means your call was wrong.
  • The ~~~^^^ markers name the exact sub-expression, which matters most on lines doing several things.
  • No Traceback header means the file never ran — a syntax or indentation problem.
  • "During handling" points at a bug in an except block; "direct cause" means somebody re-raised on purpose.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.