Python fundamentals

Lesson 1 — Python

Lesson 1 of 20 Beginner ~75 min

Learning objectives

  • Run Python from the REPL, a script and a notebook
  • Use the core scalar types and understand dynamic typing
  • Write conditionals and loops with correct indentation
  • Format strings with f-strings
  • Import and use modules
  • Read a Python traceback

Running Python

python                          # interactive REPL
python script.py                # run a script
python -m module                # run a module
python -c "print(1 + 1)"        # one-liner

ipython                         # a much better REPL
jupyter lab                     # notebooks

In a script:

#!/usr/bin/env python3
"""Compute study day from two dates."""

from datetime import date

def study_day(event: date, reference: date) -> int:
    """Return the study day; day 1 is the reference date, with no day zero."""
    delta = (event - reference).days
    return delta + 1 if delta >= 0 else delta


if __name__ == "__main__":
    print(study_day(date(2026, 3, 20), date(2026, 3, 15)))

The if __name__ == "__main__": guard means the code runs when the file is executed but not when it is imported. It is the Python equivalent of keeping side effects out of a sourced R file, and it is a strong convention rather than an option.

Indentation is syntax

Python has no braces. Indentation defines blocks.

if age >= 65:
    group = "elderly"
    print(group)
else:
    group = "adult"

print("done")     # outside the if
# R equivalent
if (age >= 65) {
  group <- "elderly"
  print(group)
} else {
  group <- "adult"
}
print("done")

Four spaces per level, never tabs. Mixing them is a syntax error, and the error message is unhelpful. Configure your editor to insert spaces.

Types

x = 42              # int
y = 3.14            # float
name = "Ram"        # str
flag = True         # bool  (capitalised!)
nothing = None      # NoneType
c = 1 + 2j          # complex

type(x)             # <class 'int'>
isinstance(x, int)  # True

Python is dynamically typed but strongly typed: a variable can hold any type, but types are not silently coerced.

"1" + 1
# TypeError: can only concatenate str (not "int") to str

1 + True            # 2      — bool IS a subclass of int

Numbers

7 / 2               # 3.5      true division, always float
7 // 2              # 3        floor division
7 % 2               # 1        modulo
2 ** 10             # 1024     exponent (R uses ^)
abs(-5)             # 5
round(3.567, 2)     # 3.57
divmod(7, 2)        # (3, 1)

int("42")           # 42
float("3.14")       # 3.14
int(3.99)           # 3        truncates, does not round
Warninground() uses banker’s rounding
round(0.5)    # 0
round(1.5)    # 2
round(2.5)    # 2

Same as R, different from SAS. For half-up rounding:

from decimal import Decimal, ROUND_HALF_UP

def round_half_up(x, digits=0):
    q = Decimal(10) ** -digits
    return float(Decimal(str(x)).quantize(q, rounding=ROUND_HALF_UP))

round_half_up(0.5)   # 1.0
round_half_up(2.5)   # 3.0

Decimal(str(x)) rather than Decimal(x) — passing a float directly carries its binary representation error into the Decimal.

Integers have unlimited precision:

2 ** 1000           # a 302-digit integer, exactly

Floats do not:

0.1 + 0.2 == 0.3    # False
0.1 + 0.2           # 0.30000000000000004

import math
math.isclose(0.1 + 0.2, 0.3)   # True

Strings

s = "Hello"
s2 = 'Hello'                    # identical; quotes are interchangeable
multi = """Line one
Line two"""

len(s)              # 5
s.upper()           # "HELLO"
s.lower()
s.strip()           # remove surrounding whitespace
s.replace("l", "L") # "HeLLo"
s.split(",")        # list
",".join(["a","b"]) # "a,b"
s.startswith("He")  # True
"ell" in s          # True

s[0]                # "H"      0-indexed!
s[-1]               # "o"      negative indexes from the end
s[1:3]              # "el"     slice: start inclusive, stop EXCLUSIVE
s[:3]               # "Hel"
s[::2]              # "Hlo"    every second character
s[::-1]             # "olleH"  reversed
Important0-indexed, and slices exclude the stop
x = [10, 20, 30, 40, 50]
x[0]      # 10     first element
x[1:3]    # [20, 30]     positions 1 and 2, NOT 3
x <- c(10, 20, 30, 40, 50)
x[1]      # 10     first element
x[2:3]    # 20 30  both endpoints included

This is the single most common source of off-by-one errors when moving between the languages. The Python convention has a virtue: x[:n] and x[n:] partition the list exactly, with no overlap or gap.

f-strings

The modern way to build strings:

name = "Ram"
age = 45
value = 3.14159

f"{name} is {age}"                  # "Ram is 45"
f"{value:.2f}"                      # "3.14"
f"{value:8.2f}"                     # "    3.14"    width 8, right aligned
f"{value:<8.2f}"                    # "3.14    "    left aligned
f"{age:03d}"                        # "045"         zero padded
f"{0.4567:.1%}"                     # "45.7%"
f"{1234567:,}"                      # "1,234,567"
f"{name=}"                          # "name='Ram'"  debugging shorthand

# Expressions work inside
f"{age * 2}"                        # "90"
f"{'elderly' if age >= 65 else 'adult'}"

The :.2f mini-language is the equivalent of sprintf("%.2f", x) in R and is worth learning properly — it appears throughout pandas and matplotlib.

Control flow

# if / elif / else
if age < 18:
    group = "child"
elif age < 65:
    group = "adult"
else:
    group = "elderly"

# Ternary
group = "elderly" if age >= 65 else "adult"

# for over any iterable
for x in [1, 2, 3]:
    print(x)

for i, x in enumerate(["a", "b", "c"]):
    print(i, x)                     # 0 a, 1 b, 2 c

for k, v in {"a": 1, "b": 2}.items():
    print(k, v)

for a, b in zip([1, 2, 3], ["x", "y", "z"]):
    print(a, b)

for i in range(5):                  # 0, 1, 2, 3, 4
    print(i)

for i in range(1, 10, 2):           # 1, 3, 5, 7, 9
    print(i)

# while
n = 0
while n < 5:
    n += 1

# break, continue, else
for x in values:
    if x < 0:
        break
    if x == 0:
        continue
    process(x)
else:
    print("completed without break")   # runs only if no break

The for ... else construct is unusual and rarely needed, but you will meet it.

Truthiness

bool(0)         # False
bool("")        # False
bool([])        # False
bool({})        # False
bool(None)      # False
bool(0.0)       # False

bool(1)         # True
bool("a")       # True
bool([0])       # True    a non-empty list is truthy even if it contains 0
if not results:              # idiomatic "if the list is empty"
    print("no results")

if x is None:                # test for None with 'is', never '=='
    ...
Warningis versus ==
a = [1, 2]
b = [1, 2]
a == b      # True   — same contents
a is b      # False  — different objects

x = None
x is None   # True   — the correct test
x == None   # works, but is not idiomatic and can be overridden

Use is for None, True and False. Use == for values.

Modules and imports

import math
math.sqrt(16)                       # 4.0

import numpy as np                  # aliased — the universal convention
np.array([1, 2, 3])

from datetime import date, timedelta
date.today()

from statistics import mean, median

import pandas as pd                 # the conventions matter; use them
import matplotlib.pyplot as plt
import seaborn as sns

Never from module import * — it pollutes the namespace and makes the origin of every name invisible. This is the same argument as preferring pkg::fn() in R.

The standard library is large and worth knowing:

import os, sys, pathlib      # filesystem and interpreter
import json, csv             # data formats
import re                    # regular expressions
import datetime              # dates and times
import itertools, functools  # functional tools
import collections           # Counter, defaultdict, namedtuple
import logging               # logging
import pathlib               # modern path handling
from pathlib import Path

p = Path("data") / "raw" / "dm.csv"      # / operator joins paths
p.exists()
p.suffix                                  # ".csv"
p.stem                                    # "dm"
list(Path("data").glob("**/*.csv"))

pathlib is the modern replacement for os.path string manipulation. Use it.

Reading a traceback

Traceback (most recent call last):
  File "analysis.py", line 42, in <module>
    result = process(data)
  File "analysis.py", line 15, in process
    return derive(df["AGE"])
  File "analysis.py", line 8, in derive
    return x / 0
ZeroDivisionError: division by zero

Read from the bottom: the error type and message, then the innermost frame. Unlike an R traceback, Python’s is ordered most-recent-last, so the last frame is where it broke.

Common exceptions:

Exception Means
NameError Variable not defined
TypeError Wrong type for the operation
ValueError Right type, wrong value
KeyError Dictionary key not present
IndexError Sequence index out of range
AttributeError Object has no such attribute or method
ImportError Module not found
FileNotFoundError Path does not exist

R and Python side by side

Concept R Python
Assignment x <- 5 x = 5
Index base 1 0
Slice endpoints Both inclusive Stop exclusive
Scalars None — length-1 vectors Yes
Blocks { } Indentation
Comment # #
Null NULL None
Missing NA None or float('nan')
Boolean TRUE / FALSE True / False
Exponent ^ **
Integer division %/% //
String concat paste0(a, b) a + b or f-string
Length length(x) len(x)
Sequence 1:5 range(1, 6)
Function f <- function(x) x def f(x): return x
Anonymous \(x) x + 1 lambda x: x + 1
Pipe \|> Method chaining .
Package library(x) import x
Help ?fn help(fn) or fn? in IPython

Common mistakes

Mistake Consequence Fix
Mixing tabs and spaces IndentationError Four spaces, editor configured
x[1] expecting the first element Off by one Python is 0-indexed
x[1:3] expecting three elements Gets two Stop is exclusive
"1" + 1 TypeError Convert explicitly
x == None Works but not idiomatic x is None
from module import * Namespace pollution Import what you need
Mutable default argument Shared state across calls See lesson 3
Reading a traceback top-down Confusion Read from the bottom

Exercise 1.1 — Translate from R

Translate this R code to Python.

classify <- function(age) {
  if (is.na(age)) {
    "Missing"
  } else if (age < 18) {
    "<18"
  } else if (age < 65) {
    "18-64"
  } else {
    ">=65"
  }
}

ages <- c(45, 72, NA, 16)
groups <- sapply(ages, classify)
for (i in seq_along(ages)) {
  cat(sprintf("Age %s -> %s\n", ages[i], groups[i]))
}
Show solution
def classify(age):
    """Return an age group label, or 'Missing' for None/NaN."""
    if age is None:
        return "Missing"
    if age < 18:
        return "<18"
    if age < 65:
        return "18-64"
    return ">=65"


ages = [45, 72, None, 16]
groups = [classify(a) for a in ages]

for age, group in zip(ages, groups):
    print(f"Age {age} -> {group}")
Age 45 -> 18-64
Age 72 -> >=65
Age None -> Missing
Age 16 -> <18

Four differences worth noting:

  • Early returns rather than a nested if/else chain. Python style favours returning as soon as the answer is known; the R version returns the value of the whole if expression, which is idiomatic R.
  • List comprehension [classify(a) for a in ages] replaces sapply(). It is the standard Python idiom for “apply a function to each element” — see lesson 2.
  • zip() iterates two sequences together, replacing the index loop. Looping over indexes to access elements is a common R habit that reads as unidiomatic in Python.
  • is None not == None.

If the missing value were float('nan') rather than None — which is what it will be coming from pandas — the check must change:

import math

def classify(age):
    if age is None or (isinstance(age, float) and math.isnan(age)):
        return "Missing"
    ...
nan is not equal to itself, so age == float('nan') is always False. This catches people constantly.

Exercise 1.2 — Format a summary line

Write format_summary(name, n, mean, sd, pct) producing output like:

Age (years)          n=254    Mean (SD): 75.14 (8.253)    Elderly: 84.9%

with the name left-padded to 20 characters and the numbers aligned.

Show solution
def format_summary(name: str, n: int, mean: float, sd: float, pct: float) -> str:
    """Format a one-line summary of a continuous variable.

    Args:
        name: Variable label, truncated to 20 characters.
        n: Number of non-missing observations.
        mean: Arithmetic mean.
        sd: Standard deviation.
        pct: Percentage, as a number between 0 and 100.
    """
    return (
        f"{name:<20.20}"
        f" n={n:<6}"
        f" Mean (SD): {mean:6.2f} ({sd:.3f})"
        f"    Elderly: {pct:.1f}%"
    )


print(format_summary("Age (years)", 254, 75.1417, 8.2534, 84.88))
print(format_summary("Weight (kg)", 251, 78.3, 15.1204, 12.5))
print(format_summary("Body Mass Index (kg/m2)", 249, 26.44, 4.9, 33.2))
Age (years)           n=254   Mean (SD):  75.14 (8.253)    Elderly: 84.9%
Weight (kg)           n=251   Mean (SD):  78.30 (15.120)    Elderly: 12.5%
Body Mass Index (kg/  n=249   Mean (SD):  26.44 (4.900)    Elderly: 33.2%

The format specifiers:

  • {name:<20.20} — left align in 20 characters, and truncate to 20. The second number is a precision, which for strings means maximum length. Without it, a long name pushes everything out of alignment, as the third row would.
  • {n:<6} — left align in 6 characters
  • {mean:6.2f} — width 6, two decimals, right aligned (the default for numbers)
  • {sd:.3f} — three decimals, no minimum width
  • {pct:.1f}% — one decimal, literal percent sign
Note the implicit string concatenation across lines: adjacent string literals join automatically, so a long f-string can be split across lines inside parentheses without +. That is idiomatic and much easier to read than one very long line.

Recap

  • Indentation is syntax; four spaces, never tabs
  • 0-indexed, and slice stops are exclusive — the main source of off-by-one bugs
  • Python has scalars; R does not
  • Strongly typed: "1" + 1 is an error, not a coercion
  • f-strings with the :.2f mini-language for all formatting
  • is None for None; == for values
  • Read tracebacks from the bottom
  • if __name__ == "__main__": keeps side effects out of importable modules

Next: Lists, tuples and dictionaries.

Back to top