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.
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.
# 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 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.
# 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.
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.
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.
# 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.
# 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.
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.
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,iscompares identity. Useisonly forNone,TrueandFalse.- 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.
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 askedint()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_configandline 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.
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.
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.
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.
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.
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
Tracebackheader means the file never ran — a syntax or indentation problem. - "During handling" points at a bug in an
exceptblock; "direct cause" means somebody re-raised on purpose.