PONY λ M2 Modula-2

R.CodeCompared.To/Python

An interactive executable cheatsheet comparing R and Python

R 4.6 Python 3.14
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.