Basics
Hello, World
cat("Hello, World!\n") print("Hello, World!") Python’s
print adds the newline cat makes you write — and unlike R’s print, it does not prefix output with [1], because what it prints is not a vector (a running theme on this page).<- becomes =
sample_size <- 30
mean_height = 170 # = also works, but style guides prefer <-
cat(sample_size, mean_height, "\n") sample_size = 30
mean_height = 170 # = is the only assignment operator
print(sample_size, mean_height) Python has one assignment operator,
=, with no arrow and no right-assign ->. Note print takes multiple arguments the way cat does. (Python’s := "walrus" exists but only inside expressions — closer to a special case than to <-.)TRUE/FALSE → True/False
is_ready <- TRUE
is_done <- F # the T/F shorthand works (and is a classic footgun)
cat(is_ready, is_done, "\n")
if (is_ready) cat("go\n") is_ready = True
is_done = False # no T/F shorthand — and none of its dangers
print(is_ready, is_done)
if is_ready:
print("go") Capitalization changes:
True/False/None. There is no T/F shorthand — which also removes R’s classic footgun of T being an ordinary variable someone can overwrite. Note the block syntax: a colon and indentation, no braces — whitespace is the structure.One-Based → Zero-Based
The first element is [0]
heights <- c(160, 172, 181)
cat(heights[1], "\n") # the first element
cat(heights[3], "\n") # the last (position = length) heights = [160, 172, 181]
print(heights[0]) # the first element
print(heights[2]) # the last (position = length - 1) Counting starts at zero: element one lives at
[0], and the last element of n items lives at [n - 1]. Every off-by-one instinct from R needs one turn of adjustment — the next two rows are where it bites hardest.🚨 Negative indexing INVERTS
heights <- c(160, 172, 181)
print(heights[-1]) # DROPS the first element: 172 181
print(heights[-2]) # drops the second: 160 181 heights = [160, 172, 181]
print(heights[-1]) # SELECTS the last element: 181
print(heights[-2]) # selects second-from-last: 172 The single nastiest trap for an R user, because both languages accept the same syntax and mean opposite things: R’s negative index excludes ("everything but position 1"); Python’s negative index selects from the end. Exclusion in Python is a slice or a comprehension, never a minus.
Slices are half-open
letters_sample <- c("a", "b", "c", "d", "e")
print(letters_sample[2:4]) # positions 2,3,4 — both ends INCLUDED letters_sample = ["a", "b", "c", "d", "e"]
print(letters_sample[1:4]) # positions 2,3,4 — end EXCLUDED
print(letters_sample[:2]) # first two
print(letters_sample[2:]) # everything from the third on A Python slice
[start:stop] includes start and excludes stop — so R’s 2:4 becomes [1:4] after both the zero-shift and the open end. The consolation prizes: stop - start is always the length, and either end may be omitted.Vectors Are Not the Default
Scalars exist (5 is not a vector)
measurement <- 5
print(length(measurement)) # 1 — even a "scalar" is a vector in R
print(is.vector(measurement)) measurement = 5
# len(measurement) would raise TypeError — an int has no length.
print(type(measurement))
print(isinstance(measurement, int)) R has no scalars —
5 is a length-one vector, which is why everything vectorizes. Python inverts this: 5 is a plain int with no length, lists are containers of scalars, and nothing vectorizes by default (next row).🚨 [1, 2, 3] * 2 repeats the list
doses <- c(1, 2, 3)
print(doses * 2) # elementwise: 2 4 6
print(doses + c(10, 20, 30)) doses = [1, 2, 3]
print(doses * 2) # REPETITION: [1, 2, 3, 1, 2, 3]
print(doses + [10, 20, 30]) # CONCATENATION: [1, 2, 3, 10, 20, 30]
print([dose * 2 for dose in doses]) # the elementwise version Arithmetic on lists is sequence algebra, not math:
* repeats and + concatenates. Elementwise work on plain lists is a comprehension — or numpy, next row, where the R instinct is restored. (There is also no recycling anywhere: mismatched lengths are an error, not a warning.)numpy is where vectors live
doses <- c(1, 2, 3)
print(doses * 2)
print(sqrt(doses))
print(mean(doses)) import numpy
doses = numpy.array([1, 2, 3])
print(doses * 2) # elementwise again — home at last
print(numpy.sqrt(doses))
print(doses.mean()) A numpy array behaves like an R vector: elementwise arithmetic, vectorized math functions, comparisons producing boolean masks. The mental translation is that base-R’s vector semantics are a library in Python — the one nearly every scientific package is built on. Note
doses.mean(): functions become methods called on the data.Sequences & Ranges
1:5 → range(1, 6)
print(1:5) # 1 2 3 4 5 — inclusive
for (trial in 1:3) {
cat("trial", trial, "\n")
} print(list(range(1, 6))) # 1..5 — the stop is EXCLUDED
for trial in range(1, 4):
print("trial", trial) range(start, stop) excludes its stop, consistent with slicing — R’s 1:5 is range(1, 6). A range is also lazy: it produces values on demand (hence list(...) to print them), and range(len(items)) is usually a smell — iterate the items directly.seq() → range step & linspace
print(seq(0, 10, by = 2))
print(seq(0, 1, length.out = 5)) import numpy
print(list(range(0, 11, 2))) # integer steps only
print(numpy.linspace(0, 1, num = 5)) # n evenly spaced points seq(by =) maps to range’s third argument for integers; fractional steps and length.out belong to numpy (arange, linspace). The split is the theme again: base Python for counting, numpy for numerics.Data Frames → pandas
data.frame → DataFrame
patients <- data.frame(
name = c("Ada", "Grace", "Mary"),
age = c(36, 41, 58)
)
print(patients)
print(nrow(patients))
str(patients) import pandas
patients = pandas.DataFrame({
"name": ["Ada", "Grace", "Mary"],
"age": [36, 41, 58],
})
print(patients)
print(len(patients))
patients.info() pandas is R’s data frame transplanted (its author was porting R idioms to Python), built from a dictionary of columns.
nrow becomes len, str() becomes .info(), and the printed frame shows pandas’ row index — a first hint that rows have names the way R’s row numbers never quite did.df$age → df["age"]
patients <- data.frame(name = c("Ada", "Grace"), age = c(36, 41))
print(patients$age)
patients$senior <- patients$age > 40
print(patients) import pandas
patients = pandas.DataFrame({"name": ["Ada", "Grace"], "age": [36, 41]})
print(patients["age"])
patients["senior"] = patients["age"] > 40
print(patients) df$column becomes df["column"] — and a column is a Series, pandas’ vector, which vectorizes like one (> 40 yields a boolean Series). Attribute access patients.age also works for reading but breaks on names that clash with methods, so the brackets are the durable habit.Row filtering
patients <- data.frame(
name = c("Ada", "Grace", "Mary"),
age = c(36, 41, 58)
)
print(patients[patients$age > 40, ])
print(subset(patients, age > 40 & name != "Mary")) import pandas
patients = pandas.DataFrame({
"name": ["Ada", "Grace", "Mary"],
"age": [36, 41, 58],
})
print(patients[patients["age"] > 40])
print(patients.query("age > 40 and name != 'Mary'")) Boolean-mask filtering translates directly — minus the trailing comma, since pandas indexing selects rows by default.
.query() plays subset(), evaluating column names inside a string. Two spellings to note: combined masks need &/| with parentheses (never and/or), and .loc[] is the explicit row/column selector R’s [rows, columns] becomes.aggregate → groupby
measurements <- data.frame(
group = c("a", "a", "b", "b"),
value = c(10, 20, 30, 40)
)
print(aggregate(value ~ group, data = measurements, FUN = mean)) import pandas
measurements = pandas.DataFrame({
"group": ["a", "a", "b", "b"],
"value": [10, 20, 30, 40],
})
print(measurements.groupby("group")["value"].mean()) The split-apply-combine idiom reads left to right as a method chain: group, select, aggregate. There is no formula interface (
value ~ group has no Python equivalent) — the chain of named steps is how pandas spells what R distributes between formulas, aggregate, and dplyr verbs.Copy-on-Modify Is Gone
🚨 Assignment aliases
original <- c(1, 2, 3)
copied <- original # R copies on modify —
copied[1] <- 99 # this touches only the copy
print(original) # 1 2 3, untouched original = [1, 2, 3]
aliased = original # NOT a copy — the same list
aliased[0] = 99
print(original) # [99, 2, 3] — changed through the alias!
independent = original.copy()
independent[0] = 1
print(original) # unaffected by the real copy The safety net is gone: R’s copy-on-modify means assignment behaves like a copy; Python assignment just points a second name at the same object, and mutation is visible through every alias.
.copy() (or df.copy() in pandas) restores the R behavior — explicitly, every time.Functions can mutate their arguments
add_measurement <- function(values, new_value) {
values <- c(values, new_value) # modifies a local copy only
values
}
readings <- c(1, 2)
extended <- add_measurement(readings, 3)
print(readings) # 1 2 — the caller's data is always safe
print(extended) def add_measurement(values, new_value):
values.append(new_value) # mutates the CALLER's list
return values
readings = [1, 2]
extended = add_measurement(readings, 3)
print(readings) # [1, 2, 3] — changed by the function!
print(extended) In R, a function cannot change your data — arguments are effectively copies. In Python, mutable arguments are passed as references, and
append-style methods change the caller’s object. Well-behaved code either mutates deliberately and documents it, or builds new values (values + [new_value]) — but the language no longer enforces the choice.Functions
return is not optional
standardize <- function(values) {
(values - mean(values)) / sd(values) # last expression is returned
}
print(standardize(c(10, 20, 30))) def standardize(values):
average = sum(values) / len(values)
spread = (sum((value - average) ** 2 for value in values) / (len(values) - 1)) ** 0.5
return [(value - average) / spread for value in values]
print(standardize([10, 20, 30])) A Python function without
return returns None — silently. R’s last-expression-is-the-value habit is the source of a classic migration bug: a function that computes everything and returns nothing. Note also base Python has no mean/sd; they live in statistics or numpy.... → *args / **kwargs
report <- function(label, ...) {
extras <- list(...)
cat(label, ":", length(extras), "extra values\n")
}
report("run", 1, 2, 3) def report(label, *values, **options):
print(label, ":", len(values), "extra values")
print("options:", options)
report("run", 1, 2, 3, verbose=True) R’s single
... splits into two: *values collects extra positional arguments as a tuple, **options collects extra named arguments as a dictionary. Argument passing is otherwise familiar — positional, named (verbose=True), defaults in the signature — with one famous trap: default values are evaluated once, so a mutable default like def f(items=[]) is shared across calls.\(x) → lambda
double <- \(value) value * 2 # the 4.1+ lambda shorthand
print(double(21))
print(sapply(c(1, 2, 3), \(value) value ^ 2)) double = lambda value: value * 2
print(double(21))
print([value ** 2 for value in [1, 2, 3]]) R’s
\(x) shorthand becomes lambda x: — limited to a single expression, and style guides prefer a named def for anything reused. The second line foreshadows the next section: where R reaches for sapply + lambda, Python usually reaches for a comprehension and skips the lambda entirely. Exponentiation is **, not ^ (which is XOR!).apply Family → Comprehensions
sapply → list comprehension
doses <- c(10, 20, 30)
print(sapply(doses, \(dose) dose / 10))
print(sapply(doses, \(dose) dose > 15))
print(doses[sapply(doses, \(dose) dose > 15)]) doses = [10, 20, 30]
print([dose / 10 for dose in doses])
print([dose > 15 for dose in doses])
print([dose for dose in doses if dose > 15]) # filter built into the syntax The comprehension is Python’s
sapply, with the filter clause built in — [expr for item in items if condition] covers sapply + logical-subsetting in one readable line. It is the single most idiomatic construct in the language, the way vectorized subsetting is in R.lapply & named lists → dict comprehensions
samples <- list(control = c(1, 2, 3), treatment = c(4, 5, 6))
averages <- lapply(samples, mean)
print(averages) samples = {"control": [1, 2, 3], "treatment": [4, 5, 6]}
averages = {name: sum(values) / len(values) for name, values in samples.items()}
print(averages) R’s named list is Python’s
dict — the workhorse container, with .items() yielding name/value pairs. The dict comprehension plays lapply over one, preserving names. (Loops are also perfectly idiomatic Python — there is no vectorize-or-else performance culture for ordinary code.)NA → None & NaN
One NA → None and nan
reading <- NA # missing, typed, integrates with everything
print(is.na(reading))
print(NA + 1) # NA propagates
print(mean(c(1, 2, NA)))
print(mean(c(1, 2, NA), na.rm = TRUE)) no_value = None # absence — but not numeric-aware
not_a_number = float("nan") # the float NaN
print(no_value is None)
print(not_a_number != not_a_number) # nan is not even equal to itself
# None + 1 raises TypeError — absence does not propagate, it explodes R’s single, typed, propagating
NA splits into pieces: None (generic absence, not numeric, raises on arithmetic) and the float nan (numeric, propagates, unequal even to itself). Base Python has no na.rm story at all — that lives in pandas, next row.na.rm → skipna (pandas)
values <- c(1, 2, NA, 4)
print(mean(values)) # NA — you must opt out
print(mean(values, na.rm = TRUE))
print(sum(is.na(values))) import pandas
values = pandas.Series([1, 2, None, 4])
print(values.mean()) # skips missing BY DEFAULT
print(values.mean(skipna=False)) # opt back in to propagation
print(values.isna().sum()) pandas restores an R-like missing story (
None becomes NaN in a Series, isna() plays is.na) with one default flipped: aggregations skip missing values unless told otherwise — the opposite of R’s conservative NA-propagation. Know which default you are standing on.Strings
paste & sprintf → f-strings
name <- "Ada"
trials <- 3
cat(paste("Subject", name, "completed", trials, "trials"), "\n")
cat(sprintf("%s: %.1f%% done\n", name, 66.67)) name = "Ada"
trials = 3
print(f"Subject {name} completed {trials} trials")
print(f"{name}: {66.67:.1f}% done") The f-string covers both
paste and sprintf: expressions interpolate inside {}, and format specs ride after a colon (:.1f). Joining a vector of strings inverts subject and verb — R’s paste(words, collapse = ", ") is Python’s ", ".join(words), called on the separator.String functions → methods
phrase <- "stitch in time"
print(toupper(phrase))
print(nchar(phrase))
print(strsplit(phrase, " ")[[1]])
print(sub("time", "space", phrase))
print(grepl("time", phrase)) phrase = "stitch in time"
print(phrase.upper())
print(len(phrase))
print(phrase.split(" "))
print(phrase.replace("time", "space"))
print("time" in phrase) Function-wrapping becomes method-calling:
toupper(x) → x.upper(), and the strsplit(...)[[1]] dance disappears because split returns a plain list. Membership is the readable in operator; regular expressions move to the re module (replace here is literal, unlike sub).S3 Generics → Methods
S3 generics → methods on classes
# S3: a class is a tag; behavior lives in generic functions.
patient <- list(name = "Ada", age = 36)
class(patient) <- "patient"
print.patient <- function(x, ...) {
cat("Patient:", x$name, "- age", x$age, "\n")
}
print(patient) # dispatches to print.patient class Patient:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"Patient: {self.name} - age {self.age}"
ada = Patient("Ada", 36)
print(ada) # print() consults __str__ The dispatch direction flips: S3 attaches a class tag to data and lets generic functions choose an implementation; Python puts the methods inside the class, and dunder methods like
__str__ are the hooks built-ins consult. self is explicit in every method signature, and __init__ plays the constructor R never quite formalized.Everything has methods
values <- c(3, 1, 2)
print(sort(values)) # functions wrap the data
print(rev(sort(values)))
print(round(mean(values), 1)) values = [3, 1, 2]
values.sort() # a METHOD — and it mutates in place!
print(values)
print(sorted([3, 1, 2], reverse=True)) # the non-mutating function
print(round(sum(values) / len(values), 1)) Much of the standard library hangs off the values themselves — and method spelling often signals mutation:
values.sort() sorts in place and returns None (assigning its result is another classic migration bug), while the function sorted(values) returns a new list like R’s sort always did.Error Handling
tryCatch → try/except
result <- tryCatch({
stop("model failed to converge")
}, error = function(condition) {
cat("caught:", conditionMessage(condition), "\n")
NA
}, finally = {
cat("cleanup\n")
})
print(result) def fit_model():
raise RuntimeError("model failed to converge")
try:
result = fit_model()
except RuntimeError as error:
print("caught:", error)
result = None
finally:
print("cleanup")
print(result) stop becomes raise with a typed exception, and the handler is a block rather than a function — except RuntimeError as error plays the error = handler, finally transfers by name. Exceptions are classes, so handlers can be as specific (FileNotFoundError) or broad (Exception) as the R condition system’s classes allowed.warning() → warnings module
check_sample <- function(sample_size) {
if (sample_size < 30) {
warning("small sample; results may be unstable")
}
sample_size
}
print(check_sample(10)) import warnings
def check_sample(sample_size):
if sample_size < 30:
warnings.warn("small sample; results may be unstable")
return sample_size
print(check_sample(10)) Warnings survive as a module rather than a language feature:
warnings.warn emits without halting, and filters control whether warnings print once, always, or escalate to errors — the machinery behind the deprecation notices every Python data library uses.Pipes → Method Chains
|> becomes the dot
measurements <- c(12, 7, 25, 31, 8)
measurements |>
(\(values) values[values > 10])() |>
sort() |>
print() import pandas
measurements = pandas.Series([12, 7, 25, 31, 8])
result = (
measurements[measurements > 10]
.sort_values()
.reset_index(drop=True)
)
print(result) Method chaining is Python’s pipe: each call returns an object whose methods continue the sentence, wrapped in parentheses for the multi-line layout. Where R bolted
|> onto a function-first language, pandas was designed around chains from the start — the dplyr feel without the operator.The ecosystem map
# The tidyverse / base-R toolbox...
library(dplyr) # data manipulation
library(ggplot2) # plotting
library(tidyr) # reshaping
library(readr) # data import
# install.packages("dplyr") # ...and its Python counterparts:
import pandas # dplyr + tidyr + readr territory
import numpy # vectors and numerics
import matplotlib # plotting (seaborn/plotnine for the ggplot feel)
import sklearn # modeling / machine learning
# pip install pandas (or: uv add pandas) The rough map: pandas absorbs dplyr, tidyr, and readr; numpy is the vector substrate; matplotlib is base plotting with seaborn on top (and
plotnine is a literal ggplot2 port, grammar of graphics included); scikit-learn anchors modeling. install.packages becomes pip install into a per-project virtual environment. Both cells are import inventories, shown display-only.