Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 1

Python Fundamentals

The language itself, how code is executed, and the mechanics of names and basic I/O.

1.1 Language Overview and Execution Model

Covers what Python is, how it actually runs, the feature and limitation lists from the notes, and the two foundational claims the notes open with: Python is dynamically typed, and in Python everything is an object.

1.1.1 What Python Is

Explanation

Python is a general purpose high level programming language.

Two independent claims:

General purpose — Python is not tied to one problem domain. The same language is used for scripting, web backends, automation, data analysis, machine learning, testing, GUIs and glue code. Contrast with domain-specific languages: SQL only queries data, HTML only describes documents, MATLAB targets numerical work.

High level — the language is far from the machine. There are no registers, no pointer arithmetic, no manual malloc/free, no explicit types. You describe what should happen; the interpreter decides how.

Comparison — level of abstraction

Level Language What you must manage yourself
Low Assembly Registers, memory addresses, instructions
Mid C Memory allocation/deallocation, pointers, types
High Python Almost nothing — memory and types are handled for you

Why it matters

High level buys developer speed and readability at the cost of execution speed — which is exactly the limitation recorded later in 1.1.6. The two facts are the same trade-off seen from opposite sides.

In practice — Python's real-world footprint. Instagram's backend, Dropbox's file-sync engine, and large parts of YouTube's core services are all built substantially in Python, alongside its heavy use in data science, machine learning research, and scripting at NASA/JPL for mission tooling — a direct payoff of being general-purpose rather than tied to one problem domain.

1.1.2 Dynamically Typed Language

Explanation

Dynamically typed language: given a value, type will be assigned automatically.

You never declare a type. You bind a name to a value, and the type belongs to the value (the object), not to the name. The name is only a label pointing at an object; rebinding it to a different kind of object is legal.

Syntax

x = 10          # Python  — no type declaration
int x = 10;     // Java   — type declared, fixed forever
In [1]4 lines
x = 5
print(type(x))    # <class 'int'>
x = "hi"
print(type(x))    # <class 'str'> -- same name x, rebound to a different type of object
<class 'int'>
<class 'str'>

Important behaviour — dynamic typing is not weak typing

This is the single most common confusion around this note. Python is dynamically typed (types are resolved at runtime) and strongly typed (it will not silently convert unrelated types for you).

Comparison — two independent axes

Language Typing discipline Type strength "2" + 2 gives
Java, C++ Static (checked before running) Strong Compile error
Python Dynamic (checked while running) Strong TypeError at runtime
JavaScript Dynamic Weak "22" — silently coerced

Common mistakes

  • Saying "the variable is an integer". The object is an integer; the variable is a name.
  • Treating dynamic typing as "no types" — Python has types, it just does not require you to write them down.
  • Assuming dynamic typing implies automatic conversion. It does not.

In practice — why large Python codebases adopt mypy. Companies running Python at scale (Dropbox and Instagram are both well-documented examples) layer a static type checker like mypy/pyright on top of their codebase specifically because dynamic typing without one gets harder to safely refactor as a codebase grows — the checker gives back some of the "caught before running" guarantee a statically-typed language has, without giving up dynamic typing itself.

1.1.3 In Python, Everything Is an Object

Explanation

In Python everything is an object — variable, method, everything.

Every value you can bind to a name is an object, and every object has three things: an identity (its address, via id()), a type, and a value. Crucially this includes things that are not objects in most other languages: functions, methods, classes, modules, and even types themselves.

In [2]2 lines
x = 5
print(id(x), type(x), x)   # e.g. 132695655973232 <class 'int'> 5 -- identity, type and value, all at once
10454408 <class 'int'> 5

Functions, classes, modules, built-in types such as int, and even type itself all report as objects — nothing is a special non-object case.

Why it matters

This is not trivia; it is the property that makes large parts of Python possible. Because functions are objects, they can be stored in variables, passed as arguments, given attributes and returned from other functions — which is what enables higher-order functions (6.8.2), closures and decorators.

Edge case / precision

The note says "variable... everything", and the spirit is right, but two refinements are worth writing down now:

  • A variable is not itself an object — it is a name in a namespace that refers to an object. The object is what has identity, type and value. (id(x) returns the id of the object, not of x.)
  • Syntax is not an object. Keywords and operators (if, while, =, +) are language grammar, not values — you cannot bind if to a name. Everything that is a value is an object; the grammar is not a value.

1.1.4 Interpreted — the Execution Model

Explanation

The note lists interpreted as a feature. What actually happens is a two-stage process, and knowing both stages explains several behaviours later in the notebook.

   source.py
      │  (1) compile  — done automatically by Python
      ▼
   bytecode  ────────►  cached in __pycache__/source.cpython-3xx.pyc
      │  (2) interpret — instruction by instruction
      ▼
Python Virtual Machine (PVM)
      │
      ▼
  operating system / CPU

So Python is compiled — just not to machine code. It compiles to bytecode, a portable intermediate instruction set, which the PVM then executes. This is precisely the mechanism that delivers platform independence (1.1.5): the bytecode is the same everywhere, only the PVM differs per OS.

Important behaviour — the compile step catches errors before anything runs

Because compilation happens first and covers the whole file, a syntax error means no line executes at all. A name error is only found when execution reaches that line.

Comparison — "interpreted" is a property of the implementation, not the language

Implementation Written in How it runs your code
CPython (the default) C Compiles to bytecode, interprets it
PyPy Python (RPython) Bytecode + JIT compilation to machine code
Jython Java Compiles to Java bytecode, runs on the JVM
IronPython C# Runs on .NET

The language specification says nothing about interpretation. "Python is interpreted" is shorthand for "CPython interprets bytecode".

Common mistakes

  • Thinking .pyc files make the program run faster. They only skip recompilation at import time; execution speed is unchanged.
  • Expecting __pycache__ for the script you run directly — only imported modules get cached, not the top-level script.

Demo — watching __pycache__ appear

The diagram above is not just theory — importing a module really does leave a compiled .pyc file behind. To show this without depending on any file already on disk, the demo below creates a throwaway module in a temporary directory first.

Note — the tempfile.mkdtemp() + write-a-.py-file step below is just notebook scaffolding to get a real, importable module on disk; it has nothing to do with the bytecode-caching behaviour being demonstrated.

In [3]12 lines
import sys, tempfile, os

demo_dir = tempfile.mkdtemp()
with open(os.path.join(demo_dir, "tinymod.py"), "w") as f:
    f.write("VALUE = 42\n")

sys.path.insert(0, demo_dir)
import tinymod
print(tinymod.VALUE)                        # 42
print(sorted(os.listdir(demo_dir)))         # ['__pycache__', 'tinymod.py']
print(os.listdir(os.path.join(demo_dir, "__pycache__")))
# ['tinymod.cpython-3XX.pyc'] -- the compiled bytecode, tagged with the interpreter version
42
['__pycache__', 'tinymod.py']
['tinymod.cpython-313.pyc']

1.1.5 Features of Python

The full list from the notes, with what each one actually claims:

# Feature Meaning
1 Simple and easy to learn Small keyword set, English-like syntax, indentation instead of braces, no boilerplate
2 Freeware and open source Free to use, source available, permissive PSF licence — usable commercially
3 High level No memory management, no pointers, no type declarations (see 1.1.1)
4 Platform independent Same source runs on Windows, Linux, macOS — via bytecode + per-OS PVM
5 Portability Code migrates from one OS to another without any changes
6 Both procedure oriented and object oriented Multi-paradigm — write either style, or mix them
7 Interpreted Bytecode executed by the PVM, no separate build step (see 1.1.4)
8 Extensible Code from other languages (C/C++, Java) can be integrated into Python
9 Embedded Python code can be embedded inside programs written in other languages
10 Extensive library "Batteries included" — a large standard library plus a huge third-party ecosystem
Platform independence and portability (features 4 and 5)

The notes list these separately, and there is a real distinction:

  • Platform independence is a property of the implementation — the bytecode is OS-neutral, so the same .py file runs anywhere a PVM exists.
  • Portability is the practical consequence stated in the notes: migrating code from one OS to another without any changes.

The interpreter absorbs the OS differences — path separators, line endings, system calls — so your source does not have to.

Edge case: portability is "without changes" only while you stay inside the abstractions. Hard-coding "C:\\data\\file.txt", shelling out to dir or ls, or relying on a C extension that has no build for the target OS all break it. The language is portable; a given program is portable only if written that way.

Extensible and embedded (features 8 and 9)

These two are constantly swapped in exam answers. They are opposite directions:

Extensible :  C / C++ / Java code  ──►  used from inside Python
Embedded   :  Python code          ──►  used from inside a C / C++ / Java program

Extensibility is not theoretical — a large part of the standard library itself is implemented in C, with the Python layer only providing the interface around it.

This is also why the performance limitation in 1.1.6 is survivable: heavy work is pushed down into C extensions (NumPy, pandas) while the Python layer just orchestrates.

Both procedure oriented and object oriented (feature 6)

Python supports procedural, object-oriented and even functional style for the same task — none is "the" Python way.

In practice — this is why NumPy/pandas are fast despite being "Python." Both libraries implement their actual number-crunching in C (extensibility, in action), with the Python layer only orchestrating calls into it — which is exactly why vectorized NumPy code runs orders of magnitude faster than an equivalent hand-written Python loop over the same data.

Extensive library (feature 10)

The standard library ships with the interpreter — no installation needed for file handling, regex, JSON, databases, threading, testing, math, dates, networking and more. Nearly every chapter in this notebook is backed by a stdlib module.

1.1.6 Limitations of Python

From the notes:

1 Interpreter performance is always low

Why — every operation pays overhead that a compiled language does not:

  • bytecode is dispatched instruction by instruction at runtime,
  • types are unknown until runtime, so every operation must be looked up dynamically (a + b cannot be reduced to one CPU instruction — Python must ask what a is first),
  • every value is a heap object with a header, so even small integers cost memory,
  • in CPython, the GIL prevents threads from running Python bytecode in parallel (17.5).

Going deeper — CPython also offers an optional free-threaded build that removes the GIL entirely, distinct from the standard build most people actually run. "The GIL always applies" describes that standard build specifically, not an absolute rule for every CPython build. See 17.5 for the timed proof of the GIL's CPU-bound-vs-I/O-bound effect, and for how that proof changes on the free-threaded build.

Against a compiled language like C, pure Python loops doing equivalent arithmetic are typically one to two orders of magnitude slower.

Qualifying "always" — worth writing in the margin, because interviewers push on it:

  • CPU-heavy work is normally delegated to C extensions (NumPy, pandas), so the Python layer only orchestrates and the penalty largely disappears.
  • Alternative runtimes change the picture — PyPy's JIT, or Cython / C extensions for hot paths.
  • For I/O-bound work (network, disk, database) the bottleneck is waiting, not the interpreter, and Python is no slower than anything else.

So: the interpreter is slower, but "always low performance" describes the interpreter, not necessarily the program.

2 Mobile app development

Python has no first-class position on mobile. There is no standard Python UI toolkit for iOS or Android, app-store packaging is awkward, and runtime size and battery cost work against it. Frameworks exist (Kivy, BeeWare) but they are niche compared with Swift/Kotlin or cross-platform frameworks.

This is softening rather than fixed: CPython now includes officially supported (though lower-tier) iOS and Android build targets, so the gap is narrowing — but as a practical recommendation the limitation still holds.

Further limitations to be added if the notes list more (e.g. GIL, memory footprint, runtime-only error detection).

In practice — offloading CPU-heavy work out of the request cycle. A web backend that needs to do something genuinely CPU-intensive (resizing an image, generating a PDF, running a scoring model) typically hands that work to a background worker system like Celery instead of doing it inline while a user waits — sidestepping this exact interpreter-performance limitation rather than fighting it.

1.1.7 Distinctions Worth Pinning Down

Pairs from this section that are routinely confused — useful as a self-test:

Often confused The actual difference
Dynamically typed vs weakly typed When types are checked vs whether types are enforced. Python is dynamic and strong.
Platform independent vs portable A property of the implementation (OS-neutral bytecode) vs the practical result (no code changes when migrating).
Extensible vs embedded Other languages used inside Python vs Python used inside other languages.
Interpreted vs compiled Python does both — compiles to bytecode, then interprets it. "Interpreted" describes CPython, not the language.
Variable vs object The name vs the thing it points to. Only the object has a type.

1.2 Identifiers and Reserved Keywords in Python

Identifiers

An identifier is the name given to a variable, function, class, module or any other object in Python. Rules for a valid identifier:

  • May contain letters (a-z, A-Z), digits (0-9) and the underscore _.
  • Must not start with a digit.
  • Cannot be a reserved keyword.
  • Case-sensitive (age and Age are different identifiers).
  • No spaces or special characters (@, $, %, -, etc.) are allowed.

Reserved Keywords

Keywords are words that are reserved by Python and cannot be used as identifiers. Python 3.x has 35 reserved keywords:

False    None     True     and      as       assert   async    await
break    class    continue def      del      elif     else     except
finally  for      from     global   if       import   in       is
lambda   nonlocal not      or       pass     raise    return   try
while    with     yield
In [4]6 lines
import keyword
print(keyword.kwlist)
# ['False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class',
#  'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global',
#  'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise',
#  'return', 'try', 'while', 'with', 'yield']
['False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield']

A keyword can never be used as an identifier — trying to bind a value to one is a SyntaxError, caught here at compile time before anything runs (see 1.1.4):

Going deeper — keyword.softkwlist holds Python's soft keywords — names like match, case, _, and type that are only special in the specific syntactic position they were introduced for (match/case statements, wildcard patterns, type aliases). Everywhere else they remain perfectly ordinary identifiers, unlike the words in kwlist above.

In [5]4 lines
try:
    compile("class = 5", "<test>", "exec")
except SyntaxError as e:
    print(e)   # invalid syntax (<test>, line 1) -- 'class' is a keyword, not a valid identifier
invalid syntax (<test>, line 1)

1.3 Syntax, Statements and Code Structure

Statements — A statement is a complete instruction Python executes (an assignment, a function call, a return, an import, etc.). Normally one statement per line; ; lets you chain multiple statements on one line (discouraged), and a line ending in \, or an unclosed bracket, lets a single statement continue across multiple lines.

Indentation defines blocks — Python uses indentation, not braces {}, to mark the body of a function, loop, conditional, class, etc. Indentation is not a style choice — inconsistent indentation raises an IndentationError. Convention is 4 spaces per level; tabs and spaces must not be mixed.

The colon : introduces a block header — if, for, while, def, class, try, with all end their header line with :, followed by an indented block.

Case sensitivity — Python is case-sensitive: Name and name are different identifiers, and keywords must be written exactly as reserved (lowercase, except True, False, None).

Comments — anything after a # on a line is ignored entirely by Python; it exists purely for human readers. Comments are used constantly from chapter 2 onward (to annotate expected output, for instance) without ever being formally introduced until now.

In [6]2 lines
x = 5  # this is a comment, ignored by Python
print(x)   # 5
5

Indentation in practice — the block below (the print("adult") line) only runs because it is indented under the if; the line after it is not indented, so it is outside the if block and always runs regardless of the condition.

Going deeper — the walrus operator := assigns a value and produces it as an expression result in one step (e.g. inside an if condition). It is covered in full in chapter 3.

In [7]4 lines
age = 20
if age >= 18:
    print("adult")   # inside the block - runs because indented under if
print("done")         # outside the block - always runs, indentation ends the if
adult
done

Naming conventions (PEP 8) — the identifier rules above say what is legal; PEP 8 (the standard Python style guide) additionally recommends what is conventional: snake_case (lowercase words separated by underscores) for variables and functions, PascalCase (each word capitalized, no separators) for classes, and UPPER_CASE for constants. None of this is enforced by the interpreter — code that ignores it still runs — but following it is what makes Python code immediately readable to other Python developers.

In practice — enforced automatically, not by memory. Real teams don't rely on everyone remembering PEP 8 — a formatter (black) and a linter (ruff/flake8) run in CI and/or as a pre-commit hook, automatically rejecting or auto-fixing code that doesn't conform, so style consistency doesn't depend on manual code review catching it.

1.4 Basic Input and Output

Output — print() — Prints any number of values to standard output, converting each to its string form automatically. sep (default " ") controls the separator between values; end (default "\n") controls what is written after the last one.

Input — input([prompt]) — Reads a line of text typed by the user and always returns it as a str, regardless of what it looks like — explicit conversion (int(), float(), ...) is required to use it as another type. The optional prompt argument is displayed before the program waits for input.

Formatted output — f-strings (f"...{value}...") are the standard way to embed values inside printed text without manual string concatenation.

In [8]1 line
print("a", "b", sep="-", end="!\n")   # a-b!
a-b!

Note — the line below monkeypatches input() only so this cell can run non-interactively in the notebook; real usage is just age = int(input("Enter your age: ")).

In [9]5 lines
import builtins
builtins.input = lambda prompt="": "42"   # non-interactive stand-in for this demo only

age = int(input("Enter your age: "))
print(age)   # 42
42