← All tutorials
PythonData Analysis

Python vs R for Data Analysis: One Dataset, Two Languages

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.

Two Grammars for the Same Job

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.

The Dataset: 344 Penguins, Three Species

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  FEMALE

Data: 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.)

Reading the Shape of the Data

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   7

For 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.dtypes
species                  str
island                   str
bill_length_mm       float64
bill_depth_mm        float64
flipper_length_mm    float64
body_mass_g          float64
sex                      str
dtype: object
str(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.

Filtering Rows and Selecting Columns

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.0

dplyr 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        4450

Both 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.

Grouping and Summarizing

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  124

Notice 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   124

Without 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.

Adding a Computed Column

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.24

dplyr’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.24

Missing Values: Where the Defaults Actually Diverge

Here’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: int64
colSums(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.754385964912

R’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] NA
mean(penguins$body_mass_g, na.rm = TRUE)
[1] 4201.754

That 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 342

Sorting and Grabbing the Top Rows

Last 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.0

dplyr’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        6000

All 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.

A Few More Traps Worth Knowing

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.

More tutorials