Rather than argue in the abstract, this guide loads one real dataset into both pandas and dplyr and runs the same six operations in each — filtering, grouping, adding columns, handling missing values, and sorting — so you can see the actual syntax differences instead of taking anyone's word for it.
People ask “Python or R?” the way they ask “Mac or PC?” — as if the answer is a personality trait. It mostly isn’t. Both languages can load a CSV, group it, clean it, and summarize it, and for a huge share of everyday data analysis the two are close enough in capability that the actual answer depends on what else you’re doing with the result, not which language is “better.” Our pandas groupby guide covers the Python side of one of the operations below in more depth if you want to go deeper after this.
Short version: reach for Python if the analysis needs to plug into a larger codebase — a web app, an API, a production ML pipeline — since pandas is one library inside a general-purpose language with all of that tooling already available. Reach for R if the work is the statistics itself — a lot of academic research, biostatistics, and applied econometrics still runs on R because packages like lme4, survival, and base R’s built-in modeling functions are more mature there than their Python equivalents. For plain exploratory data analysis — filtering, grouping, summarizing, cleaning — pandas and R’s dplyr package cover nearly identical ground, just with different syntax, which is exactly what the rest of this post shows you side by side.
pandas is a data-analysis library bolted onto Python, a general-purpose language. Every pandas operation is a method call on an object: df.groupby("species").mean() reads as “take this DataFrame, call groupby on it, then call mean on the result.” dplyr is the reverse: it’s a small, deliberate grammar of about a dozen verbs (filter, select, mutate, summarise, arrange, group_by) built specifically for the shape of “take a table, do something to its rows or columns.” R itself was designed from the start as a language for statistics, so a table of data (a data.frame) and vectorized math on columns are baked into the core language rather than added by a library.
That difference in origin shows up constantly in how the code reads. dplyr chains verbs with a pipe — %>% from the magrittr package, or |> in base R since version 4.1 — so penguins %>% filter(species == "Gentoo") %>% summarise(avg = mean(body_mass_g)) reads left to right as a sentence: take penguins, filter to Gentoo, summarize the average. pandas chains methods on the object itself: penguins[penguins["species"] == "Gentoo"]["body_mass_g"].mean(). Same idea, same result, structurally different code — and that structural difference, more than any one function name, is what actually costs people time when they switch between the two.
Both languages get the same real data — the Palmer Archipelago penguins dataset, physical measurements of 344 penguins across three species (Adelie, Chinstrap, Gentoo) and three islands near Palmer Station, Antarctica. It’s public domain (CC0), collected by Dr. Kristen Gorman and the Palmer Station Long Term Ecological Research program, and it’s genuinely useful for this comparison because it has real missing data baked in — no need to fake gaps to demonstrate how each language handles them.
import pandas as pd
penguins = pd.read_csv("https://datatweets.com/datasets/blog/python-vs-r-data-analysis/penguins.csv")
penguins.head() species island bill_length_mm bill_depth_mm flipper_length_mm body_mass_g sex
0 Adelie Torgersen 39.1 18.7 181.0 3750.0 MALE
1 Adelie Torgersen 39.5 17.4 186.0 3800.0 FEMALE
2 Adelie Torgersen 40.3 18.0 195.0 3250.0 FEMALE
3 Adelie Torgersen NaN NaN NaN NaN NaN
4 Adelie Torgersen 36.7 19.3 193.0 3450.0 FEMALEData: the Palmer Archipelago penguins dataset (CC0), collected by Gorman, Williams & Fraser (2014) via the Palmer Station LTER, distributed through the palmerpenguins R package and mirrored as plain CSV. Row 3 above is already missing every measurement — that’s real, not injected for the demo.
library(readr)
penguins <- read_csv("https://datatweets.com/datasets/blog/python-vs-r-data-analysis/penguins.csv")
head(penguins, 5)# A tibble: 5 × 7
species island bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
<chr> <chr> <dbl> <dbl> <dbl> <dbl>
1 Adelie Torgersen 39.1 18.7 181 3750
2 Adelie Torgersen 39.5 17.4 186 3800
3 Adelie Torgersen 40.3 18 195 3250
4 Adelie Torgersen NA NA NA NA
5 Adelie Torgersen 36.7 19.3 193 3450
# ℹ 1 more variable: sex <chr>Same 344 rows, same already-blank fourth record — R’s tibble print just wraps to fit the console, so the # ℹ 1 more variable: sex <chr> line means a seventh column didn’t fit on screen, not that any data is missing. (The outputs in this post come from R 4.5.3 with dplyr 1.2.1, readr 2.2.0, and tidyr 1.3.2; the pandas side comes from pandas 3.0.3 on Python 3.13.2.)
Before doing anything with a new dataset, check its size and column types. .shape in pandas and dim() in R both answer “how big is this”:
penguins.shape(344, 7)dim(penguins)[1] 344 7For column types, pandas exposes .dtypes as a flat list. R’s str() (structure) is denser — it prints the type and a preview of the first few values on the same line as each column name:
penguins.dtypesspecies str
island str
bill_length_mm float64
bill_depth_mm float64
flipper_length_mm float64
body_mass_g float64
sex str
dtype: objectstr(penguins)spc_tbl_ [344 × 7] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
$ species : chr [1:344] "Adelie" "Adelie" "Adelie" "Adelie" ...
$ island : chr [1:344] "Torgersen" "Torgersen" "Torgersen" "Torgersen" ...
$ bill_length_mm : num [1:344] 39.1 39.5 40.3 NA 36.7 39.3 38.9 39.2 34.1 42 ...
$ bill_depth_mm : num [1:344] 18.7 17.4 18 NA 19.3 20.6 17.8 19.6 18.1 20.2 ...
$ flipper_length_mm: num [1:344] 181 186 195 NA 193 190 181 195 193 190 ...
$ body_mass_g : num [1:344] 3750 3800 3250 NA 3450 ...
$ sex : chr [1:344] "MALE" "FEMALE" "FEMALE" NA ...
- attr(*, "spec")=
.. cols(
.. species = col_character(),
.. island = col_character(),
.. bill_length_mm = col_double(),
.. bill_depth_mm = col_double(),
.. flipper_length_mm = col_double(),
.. body_mass_g = col_double(),
.. sex = col_character()
.. )
- attr(*, "problems")=<externalptr>R’s chr and num map onto pandas’ str and float64 above — same two underlying kinds of column, different names. The more useful difference is what each function shows you: pandas’ .dtypes is a clean list of types only, while str() also previews the first several actual values inline, right next to each column name, which is often enough on its own to spot a problem column before you’ve written a single line of analysis code.
Keep only the Gentoo penguins and drop everything but three columns. In pandas you index the DataFrame with a boolean mask, then a list of column names — both times using square brackets:
gentoo = penguins[penguins["species"] == "Gentoo"][["species", "island", "body_mass_g"]]
gentoo.head(3) species island body_mass_g
220 Gentoo Biscoe 4500.0
221 Gentoo Biscoe 5700.0
222 Gentoo Biscoe 4450.0dplyr gives filtering and selecting their own named verbs instead of overloading [] for both, and — this is the detail that trips people up on their first week with R — you refer to species and body_mass_g as bare names, not quoted strings. dplyr looks them up as columns of whichever data frame you piped in:
library(dplyr)
gentoo <- penguins %>%
filter(species == "Gentoo") %>%
select(species, island, body_mass_g)
head(gentoo, 3)# A tibble: 3 × 3
species island body_mass_g
<chr> <chr> <dbl>
1 Gentoo Biscoe 4500
2 Gentoo Biscoe 5700
3 Gentoo Biscoe 4450Both return the same 3 rows and the same 124 matching penguins in total (nrow(gentoo) in R, len(gentoo) in pandas). The dplyr version reads closer to plain English; the pandas version is more explicit about exactly what’s happening to the object at each step, since species and island really are just strings pandas uses to look up columns, nothing more.
This is the operation where the two languages’ philosophies matter least and the syntax overlap is closest — both treat “group by category, then aggregate” as a first-class idea. In pandas, groupby plus named aggregations:
summary = penguins.groupby("species").agg(
avg_mass=("body_mass_g", "mean"),
avg_bill_length=("bill_length_mm", "mean"),
n=("species", "count"),
).round(1)
summary avg_mass avg_bill_length n
species
Adelie 3700.7 38.8 152
Chinstrap 3733.1 48.8 68
Gentoo 5076.0 47.5 124Notice the n column counts the species value, not body_mass_g — pandas’ named count skips NaN in whichever column you point it at, and species never has one, so this gives the true per-group row count. Point it at body_mass_g instead and Adelie’s count quietly drops to 151, because one Adelie row has no recorded mass. That’s worth knowing before you trust a count column in any groupby summary.
dplyr’s group_by plus summarise produces the same numbers with a nearly one-to-one mapping of concepts — group_by(species) is groupby("species"), and each summarise() argument is one named aggregation, same as pandas’ named agg(). n() always counts rows per group, regardless of which column has gaps, so it matches pandas’ species-based count above without needing to think about it:
options(pillar.sigfig = 7) # tibble prints 3 significant digits by default; this widens it
species_summary <- penguins %>%
group_by(species) %>%
summarise(
avg_mass = round(mean(body_mass_g, na.rm = TRUE), 1),
avg_bill_length = round(mean(bill_length_mm, na.rm = TRUE), 1),
n = n()
)
species_summary# A tibble: 3 × 4
species avg_mass avg_bill_length n
<chr> <dbl> <dbl> <int>
1 Adelie 3700.7 38.8 152
2 Chinstrap 3733.1 48.8 68
3 Gentoo 5076 47.5 124Without that options() line, the same table would print 3701. instead of 3700.7 — tibble’s default display rounds to 3 significant digits to keep columns narrow, which is purely cosmetic and doesn’t touch the stored value, but it’s an easy thing to mistake for a real difference the first time you see it.
Gentoo penguins are noticeably heavier on average — over 5 kilos versus roughly 3.7 for the other two species — despite having a shorter average bill than Chinstrap. If grouping is a topic you want in more depth on the pandas side specifically, including transform and filter as the two other combine strategies, see the full groupby guide; the dplyr reference site is the equivalent jumping-off point for the R side, and it’s the one outbound link this post is pointing you to.
Notice the na.rm = TRUE in the R version — hold that thought, it’s the subject of the next section.
Both languages vectorize arithmetic across a whole column without a loop. pandas assigns a new column the same way you’d set a dictionary key:
penguins["bill_ratio"] = (penguins["bill_length_mm"] / penguins["bill_depth_mm"]).round(2)
penguins[["species", "bill_length_mm", "bill_depth_mm", "bill_ratio"]].head(3) species bill_length_mm bill_depth_mm bill_ratio
0 Adelie 39.1 18.7 2.09
1 Adelie 39.5 17.4 2.27
2 Adelie 40.3 18.0 2.24dplyr’s mutate does the same job as a verb instead of an assignment, and — like filter and select before it — refers to the source columns by bare name:
penguins <- penguins %>%
mutate(bill_ratio = round(bill_length_mm / bill_depth_mm, 2))
penguins %>%
select(species, bill_length_mm, bill_depth_mm, bill_ratio) %>%
head(3)# A tibble: 3 × 4
species bill_length_mm bill_depth_mm bill_ratio
<chr> <dbl> <dbl> <dbl>
1 Adelie 39.1 18.7 2.09
2 Adelie 39.5 17.4 2.27
3 Adelie 40.3 18 2.24Here’s the one difference in this whole post that will genuinely cost you a wrong number if you don’t know it going in. Both languages count missing values almost identically — .isna().sum() in pandas, colSums(is.na(...)) in R:
penguins.isna().sum()species 0
island 0
bill_length_mm 2
bill_depth_mm 2
flipper_length_mm 2
body_mass_g 2
sex 11
bill_ratio 2
dtype: int64colSums(is.na(penguins)) species island bill_length_mm bill_depth_mm
0 0 2 2
flipper_length_mm body_mass_g sex bill_ratio
2 2 11 2 But averaging a column with missing values is where the two languages ship opposite defaults. pandas quietly skips NaN values in .mean() unless you tell it not to:
penguins["body_mass_g"].mean()4201.754385964912R’s mean() does the opposite: if a single value in the vector is NA, the whole result becomes NA, on the theory that silently guessing what to do with missing data is a worse default than refusing to answer:
mean(penguins$body_mass_g)[1] NAmean(penguins$body_mass_g, na.rm = TRUE)[1] 4201.754That second call, with na.rm = TRUE set explicitly, is what actually matches pandas’ number. Miss that argument in R and you don’t get an error — you get NA silently propagating into whatever you compute next, which is a much easier bug to ship than a crash. pandas’ behavior is more convenient by default; R’s is more honest about forcing you to notice the gap exists. Which one you’d rather have depends on whether you want a fast first look or a script you’re about to trust with a real analysis.
Dropping the incomplete rows entirely works the same way conceptually — dropna() in pandas, drop_na() in tidyr — just spelled differently:
clean = penguins.dropna(subset=["body_mass_g"])
len(penguins), len(clean)(344, 342)library(tidyr)
clean <- penguins %>% drop_na(body_mass_g)
c(nrow(penguins), nrow(clean))[1] 344 342Last operation: which three penguins are heaviest? pandas sorts with sort_values, descending when you ask for it, then slices with .head():
penguins.sort_values("body_mass_g", ascending=False)[["species", "island", "body_mass_g"]].head(3) species island body_mass_g
237 Gentoo Biscoe 6300.0
253 Gentoo Biscoe 6050.0
297 Gentoo Biscoe 6000.0dplyr’s arrange sorts ascending by default too, and desc() flips it — same shape as ascending=False, just as a wrapper function instead of an argument:
penguins %>%
arrange(desc(body_mass_g)) %>%
select(species, island, body_mass_g) %>%
head(3)# A tibble: 3 × 3
species island body_mass_g
<chr> <chr> <dbl>
1 Gentoo Biscoe 6300
2 Gentoo Biscoe 6050
3 Gentoo Biscoe 6000All three heaviest penguins in the dataset are Gentoos from Biscoe Island — consistent with the group averages from earlier, where Gentoo’s mean body mass ran well above the other two species.
R counts from 1, Python from 0. penguins[1, ] in R is the first row; the equivalent in pandas, penguins.iloc[0], is also the first row. Every off-by-one bug you’ll hit moving between the languages traces back to this one fact — it’s worth just memorizing rather than re-deriving each time.
Bare column names only work inside the right context. Every dplyr example above referred to columns as species or body_mass_g with no quotes — that’s dplyr’s non-standard evaluation, and it only works inside dplyr verbs operating on a data frame. Try species on its own outside a filter() or mutate() call and R throws object 'species' not found, because outside that context it really is looking for a plain variable, not a column. pandas never has this ambiguity: a column reference is always a quoted string, so penguins["species"] works identically no matter where you write it.
R’s assignment operator is <-, not =. You’ll see = work inside function calls (mean(x, na.rm = TRUE)), but for creating or reassigning a variable, R’s convention is <-. Using = for both is legal R but will mark you as coming from another language the moment another R user reads your code.
Once you’ve run the same six operations in both, the syntax gap stops feeling like two different skills and starts looking like two dialects of the same underlying idea: filter rows, pick columns, group and summarize, add a column, handle the gaps honestly, sort. If you want the pandas side of this pattern in more depth — plus transform, filter, and multi-column grouping that this post didn’t have room for — the GroupBy and Aggregation lesson in our free Python for Data Analytics course picks up exactly where the pandas half of this post leaves off.