
dplyr vs pandas: A Side-by-Side Guide for R and Python
The same data-wrangling moves in both languages — and the one idea that maps one to the other
A practical, side-by-side comparison of dplyr (R) and pandas (Python): the verbs-and-pipe vs. methods-and-chaining mental model, a dplyr-to-pandas translation table, and the core operations — filter, select, mutate, arrange, group_by + summarise, joins, distinct/count, and pivot (long <-> wide) — in both, plus an honest “when to use which”.
- The one mental-model difference between dplyr and pandas — get this and the rest is syntax.
- A translation table: every dplyr verb and its pandas equivalent.
- The core wrangling moves — filter, select, mutate, arrange, group + summarise, join, distinct, pivot — in both.
- An honest when-to-use-which for anyone straddling R and Python.
If you know how to wrangle data in one of these tools and now have to do it in the other, you don’t need to relearn data manipulation — you need a map from the verbs you already know to their twin in the other language. This guide is that map. The R code below runs and prints the result you see; the Python is verified pandas you can paste and run, with each result described in prose.
The one difference that explains everything
dplyr gives you a small set of verbs — filter(), select(), mutate(), arrange(), group_by(), summarise(), left_join() — each of which takes a data frame and returns a data frame. You compose them with the pipe (|>, R’s native operator, or the older %>% from magrittr), which feeds the result of one step in as the first argument of the next. A dplyr script reads top-to-bottom as a sentence: take this data, then filter it, then group it, then summarise it.
pandas is object-oriented. Your data lives in a DataFrame, and every operation is a method called on that object — df.query(), df.assign(), df.sort_values(), df.groupby(), df.merge(). You compose them with method chaining: df.query(...).groupby(...).agg(...), where each method returns a new DataFrame that the next method acts on.
That’s the whole story. dplyr composes free-standing verbs with a pipe; pandas chains methods on an object. Both are “data frame in, data frame out” pipelines — so once you know the verb-to-method mapping, translating is mechanical:
| Task | dplyr (R) | pandas (Python) |
|---|---|---|
| Keep rows by a condition | filter(score > 80) |
df[df["score"] > 80] or df.query("score > 80") |
| Keep / drop columns | select(name, score) |
df[["name", "score"]] / df.drop(columns=...) |
| Create or change a column | mutate(pass = score >= 80) |
df.assign(pass=df["score"] >= 80) |
| Sort rows | arrange(desc(score)) |
df.sort_values("score", ascending=False) |
| Group + summarise | group_by(team) |> summarise(m = mean(score)) |
df.groupby("team").agg(m=("score", "mean")) |
| Rename columns | rename(exam = score) |
df.rename(columns={"score": "exam"}) |
| Distinct rows | distinct(team) |
df[["team"]].drop_duplicates() |
| Count rows per group | count(team) |
df["team"].value_counts() |
| Join two tables | left_join(a, b, by = "id") |
pd.merge(a, b, on="id", how="left") |
| Long → wide | pivot_wider(names_from, values_from) |
df.pivot(index, columns, values) |
| Wide → long | pivot_longer(cols, names_to, values_to) |
df.melt(id_vars, var_name, value_name) |
| Chain steps | the pipe |> / %>% |
method chaining .a().b().c() |
Keep this table open the first few times; you’ll stop needing it fast.
The core verbs, both ways
Each operation below shows the dplyr version (executed — that’s the output you see) and the pandas equivalent (verified, with its result described). To make the two sides directly comparable, both use the same tiny table of six people on two teams:
library(dplyr)
employees <- data.frame(
name = c("Ada", "Ben", "Cara", "Dan", "Eve", "Finn"),
team = c("A", "B", "A", "B", "A", "B"),
score = c(90, 70, 60, 95, 82, 78),
years = c(2, 5, 3, 8, 4, 6)
)
employees name team score years
1 Ada A 90 2
2 Ben B 70 5
3 Cara A 60 3
4 Dan B 95 8
5 Eve A 82 4
6 Finn B 78 6
The pandas side builds the identical frame with pd.DataFrame, so you can match every result row for row:
import pandas as pd
employees = pd.DataFrame({
"name": ["Ada", "Ben", "Cara", "Dan", "Eve", "Finn"],
"team": ["A", "B", "A", "B", "A", "B"],
"score": [90, 70, 60, 95, 82, 78],
"years": [2, 5, 3, 8, 4, 6],
})Filter rows
Keep only the rows that meet a condition. dplyr’s filter() takes the condition directly. pandas has two idioms: a boolean mask (df[df["score"] > 80] — index the frame with a True/False Series) or the more readable df.query("score > 80").
library(dplyr)
employees <- data.frame(
name = c("Ada", "Ben", "Cara", "Dan", "Eve", "Finn"),
team = c("A", "B", "A", "B", "A", "B"),
score = c(90, 70, 60, 95, 82, 78),
years = c(2, 5, 3, 8, 4, 6)
)
employees |> filter(score > 80) name team score years
1 Ada A 90 2
2 Dan B 95 8
3 Eve A 82 4
# boolean mask — the everyday idiom
employees[employees["score"] > 80]
# or, more readable for compound conditions
employees.query("score > 80")Output: both return the three rows with a score above 80 — Ada (90), Dan (95), and Eve (82) — identical to the dplyr result. Combine conditions with &/| in a mask (employees[(employees["score"] > 80) & (employees["team"] == "A")]) or plain and/or words inside query().
Select and rename columns
select() keeps (or, with -, drops) columns by name; rename() changes column names. In pandas you pick columns by passing a list of names, df[["name", "score"]], and rename with a {old: new} dictionary.
library(dplyr)
employees <- data.frame(
name = c("Ada", "Ben", "Cara", "Dan", "Eve", "Finn"),
team = c("A", "B", "A", "B", "A", "B"),
score = c(90, 70, 60, 95, 82, 78),
years = c(2, 5, 3, 8, 4, 6)
)
employees |>
select(name, score) |>
rename(exam_score = score) |>
head(3) name exam_score
1 Ada 90
2 Ben 70
3 Cara 60
(employees[["name", "score"]]
.rename(columns={"score": "exam_score"})
.head(3))Output: a two-column frame (name, exam_score) showing the first three people — Ada 90, Ben 70, Cara 60. Note the direction of rename: dplyr writes new = old, pandas writes {old: new} — an easy one to flip.
Create or change a column
mutate() adds a new column (or overwrites an existing one) computed from the others. pandas’ df.assign() is the chain-friendly twin; the common in-place form is df["new"] = ....
library(dplyr)
employees <- data.frame(
name = c("Ada", "Ben", "Cara", "Dan", "Eve", "Finn"),
team = c("A", "B", "A", "B", "A", "B"),
score = c(90, 70, 60, 95, 82, 78),
years = c(2, 5, 3, 8, 4, 6)
)
employees |>
mutate(passed = score >= 80) |>
head(3) name team score years passed
1 Ada A 90 2 TRUE
2 Ben B 70 5 FALSE
3 Cara A 60 3 FALSE
# chain-friendly (returns a new frame)
employees.assign(passed=employees["score"] >= 80).head(3)
# or the everyday in-place form
employees["passed"] = employees["score"] >= 80Output: the table gains a passed column of True/False — True for Ada (90), False for Ben (70) and Cara (60). assign() returns a new frame (good inside a chain); df["passed"] = ... mutates in place.
Sort rows
arrange() sorts ascending; wrap a column in desc() for descending. pandas uses df.sort_values() with ascending=False.
library(dplyr)
employees <- data.frame(
name = c("Ada", "Ben", "Cara", "Dan", "Eve", "Finn"),
team = c("A", "B", "A", "B", "A", "B"),
score = c(90, 70, 60, 95, 82, 78),
years = c(2, 5, 3, 8, 4, 6)
)
employees |> arrange(desc(score)) name team score years
1 Dan B 95 8
2 Ada A 90 2
3 Eve A 82 4
4 Finn B 78 6
5 Ben B 70 5
6 Cara A 60 3
employees.sort_values("score", ascending=False)Output: the six rows ordered from highest to lowest score — Dan (95), Ada (90), Eve (82), Finn (78), Ben (70), Cara (60). Sort by several columns by passing a list to both arguments, e.g. sort_values(["team", "score"], ascending=[True, False]).
Group and summarise
The heart of most analyses: split the data by a grouping column, then compute one summary per group. dplyr’s group_by() + summarise() is pandas’ groupby() + agg(). The named-aggregation form of agg — new_name=("column", "function") — reads almost exactly like summarise().
library(dplyr)
employees <- data.frame(
name = c("Ada", "Ben", "Cara", "Dan", "Eve", "Finn"),
team = c("A", "B", "A", "B", "A", "B"),
score = c(90, 70, 60, 95, 82, 78),
years = c(2, 5, 3, 8, 4, 6)
)
employees |>
group_by(team) |>
summarise(avg_score = mean(score), n = n())# A tibble: 2 × 3
team avg_score n
<chr> <dbl> <int>
1 A 77.3 3
2 B 81 3
(employees
.groupby("team", as_index=False)
.agg(avg_score=("score", "mean"), n=("score", "size")))Output: one row per team — team A averages 77.3 over 3 people, team B averages 81.0 over 3. Two pandas details worth knowing: as_index=False keeps team as a normal column (otherwise it becomes the row index), and size counts rows the way n() does. Without named aggregation you’d write employees.groupby("team")["score"].mean(), which returns just the means.
Distinct and count
distinct() drops duplicate rows; count() tallies rows per group. Their pandas twins are drop_duplicates() and value_counts().
library(dplyr)
employees <- data.frame(
name = c("Ada", "Ben", "Cara", "Dan", "Eve", "Finn"),
team = c("A", "B", "A", "B", "A", "B"),
score = c(90, 70, 60, 95, 82, 78),
years = c(2, 5, 3, 8, 4, 6)
)
employees |> distinct(team) # unique teams team
1 A
2 B
employees |> count(team) # rows per team team n
1 A 3
2 B 3
employees[["team"]].drop_duplicates() # unique teams -> A, B
employees["team"].value_counts() # rows per team -> A: 3, B: 3Output: drop_duplicates() returns the two distinct teams (A, B); value_counts() reports 3 rows in each. One gotcha: value_counts() sorts by frequency (largest first) and returns a Series indexed by the team, whereas dplyr’s count() returns a tidy two-column data frame — add .reset_index(name="n") in pandas if you want the same shape.
Join two tables
Combining tables on a shared key is left_join() / inner_join() in dplyr and a single pd.merge() with a how= argument in pandas. A left join keeps every row of the left table (filling unmatched keys with NA in R, NaN in pandas); an inner join keeps only the keys present in both.
library(dplyr)
scores <- data.frame(id = c(1, 2, 3), math = c(90, 80, 70))
grades <- data.frame(id = c(2, 3, 4), grade = c("B", "C", "D"))
left_join(scores, grades, by = "id") # keep all of `scores` id math grade
1 1 90 <NA>
2 2 80 B
3 3 70 C
inner_join(scores, grades, by = "id") # keep only matching ids id math grade
1 2 80 B
2 3 70 C
pd.merge(scores, grades, on="id", how="left") # keep all of `scores`
pd.merge(scores, grades, on="id", how="inner") # keep only matching idsOutput: the left join returns all three score rows; id 1 has no grade, so its grade is NaN (pandas) / NA (R). The inner join returns only ids 2 and 3, the two present in both tables. how= also takes "right" and "outer", matching dplyr’s right_join() and full_join().
Reshape: long ↔︎ wide
Reshaping between long and wide layouts is the operation people most often have to look up. dplyr’s tidyr provides pivot_wider() and pivot_longer(); pandas has pivot() and melt().
library(dplyr)
library(tidyr)
long <- data.frame(
city = c("Paris", "Paris", "Lyon", "Lyon"),
quarter = c("Q1", "Q2", "Q1", "Q2"),
sales = c(10, 15, 8, 12)
)
# long -> wide: one column per quarter
wide <- long |>
pivot_wider(names_from = quarter, values_from = sales)
wide# A tibble: 2 × 3
city Q1 Q2
<chr> <dbl> <dbl>
1 Paris 10 15
2 Lyon 8 12
# wide -> long: back to one row per city-quarter
wide |>
pivot_longer(cols = c(Q1, Q2), names_to = "quarter", values_to = "sales")# A tibble: 4 × 3
city quarter sales
<chr> <chr> <dbl>
1 Paris Q1 10
2 Paris Q2 15
3 Lyon Q1 8
4 Lyon Q2 12
long = pd.DataFrame({
"city": ["Paris", "Paris", "Lyon", "Lyon"],
"quarter": ["Q1", "Q2", "Q1", "Q2"],
"sales": [10, 15, 8, 12],
})
# long -> wide (pivot_wider)
wide = long.pivot(index="city", columns="quarter", values="sales").reset_index()
# wide -> long (pivot_longer)
wide.melt(id_vars="city", var_name="quarter", value_name="sales")Output: pivot/pivot_wider turns the four long rows into one row per city with Q1 and Q2 columns (Paris 10/15, Lyon 8/12); melt/pivot_longer reverses it back to one row per city-quarter. Two pandas notes: pivot() puts the index (city) into the row index, so .reset_index() returns it to a normal column; and for aggregating pivots — duplicate index/column pairs that need summing or averaging — reach for pivot_table(..., aggfunc="mean"), the twin of pivot_wider(values_fn = mean).
The pipe vs method chaining
Everything above composes. A real analysis strings verbs together — and this is where the two mental models sit side by side most clearly. Say we want the average score per team, but only for people who scored at least 75, sorted from best team down:
library(dplyr)
employees <- data.frame(
name = c("Ada", "Ben", "Cara", "Dan", "Eve", "Finn"),
team = c("A", "B", "A", "B", "A", "B"),
score = c(90, 70, 60, 95, 82, 78),
years = c(2, 5, 3, 8, 4, 6)
)
employees |>
filter(score >= 75) |>
group_by(team) |>
summarise(avg = mean(score)) |>
arrange(desc(avg))# A tibble: 2 × 2
team avg
<chr> <dbl>
1 B 86.5
2 A 86
(employees
.query("score >= 75")
.groupby("team", as_index=False)["score"].mean()
.rename(columns={"score": "avg"})
.sort_values("avg", ascending=False))Output: both give team B first (average 86.5) then team A (86.0), after dropping the three sub-75 scorers. Read the two pipelines line by line — filter ↔︎ query, group_by |> summarise(mean) ↔︎ groupby(...).mean(), arrange(desc()) ↔︎ sort_values(ascending=False). The wrapping parentheses in Python are what let you break a method chain across lines the way the pipe lets you break a dplyr chain.
The two styles converge even further if you use pandas’ .pipe() method or the third-party siuba / tidypolars libraries, which port dplyr’s verbs to Python almost verbatim. But you rarely need them — plain method chaining already reads as a clean, top-to-bottom pipeline once you get used to the parentheses.
When to use which
Skip the tribalism — pick by context:
- Use dplyr when your work lives in R, when you value a small, consistent verb vocabulary that reads like prose, and when you lean on the wider tidyverse (tidyr for reshaping,
stringr,lubridate, ggplot2 downstream). The verbs are deliberately few and composable; grouped summaries and joins are especially concise. - Use pandas when your stack is Python, when you need to hand data to scikit-learn, numpy, or a deep-learning framework, or when you want the deep, mature ecosystem around it (time series, I/O for every format,
merge/groupbyat scale). It does far more than dplyr’s verbs — it is R’sdata.frame, dplyr, and tidyr rolled into one object. - Don’t fight the tool. pandas’ index (the row labels that survive operations and quietly re-align across frames) has no dplyr analogue and trips up R users constantly — reach for
reset_index()andas_index=Falseearly. Equally, dplyr’s non-standard evaluation (bare column names, no quotes) feels like magic to Python users; embrace it rather than quoting everything.
The good news for anyone straddling both languages: the operations — filter, select, mutate, arrange, group-and-summarise, join, reshape — are the same handful of ideas. Learn them once and you’re fluent in both.
Frequently asked questions
They solve the same problem — manipulating tabular data — with a different design. dplyr is a small set of free-standing verbs composed with a pipe; pandas is a DataFrame object with methods composed by chaining. Most dplyr verbs map one-to-one onto a pandas method (filter → query/mask, mutate → assign, group_by + summarise → groupby + agg, left_join → merge), so translating between them is mechanical. The biggest conceptual gap is pandas’ row index, which has no dplyr equivalent.
pandas covers all of dplyr’s functionality, just with method-chaining syntax instead of verbs and a pipe. If you want dplyr’s exact verb syntax in Python, libraries like siuba and tidypolars port it closely (filter, mutate, summarize on a DataFrame), and polars offers a fast, expression-based API that many former dplyr users prefer. For most work, though, plain pandas method chaining is the standard and is all you need.
Use groupby() followed by agg() with named aggregation, which reads like summarise(): df.groupby("team", as_index=False).agg(avg=("score", "mean"), n=("score", "size")). Add as_index=False so the grouping column stays a normal column instead of becoming the row index. For a single summary you can shortcut to df.groupby("team")["score"].mean().
pivot_wider() (long → wide) maps to df.pivot(index=, columns=, values=) — or df.pivot_table(..., aggfunc=) when the reshape also needs to aggregate. pivot_longer() (wide → long) maps to df.melt(id_vars=, var_name=, value_name=). After pivot(), call .reset_index() to turn the index back into an ordinary column, since pandas moves the pivot’s index column into the row index.
Learn the one that matches your stack: dplyr if you work in R, pandas if you work in Python — the concepts transfer either way, so the second one is mostly a syntax swap. If you have a free choice, pandas has the larger job market and the deeper machine-learning ecosystem, while dplyr has the gentler, more readable learning curve. Many data scientists end up using both and lean on a translation map like the table above.
Citation
@online{kassambara2026,
author = {Kassambara, Alboukadel},
title = {Dplyr Vs Pandas: {A} {Side-by-Side} {Guide} for {R} and
{Python}},
date = {2026-07-14},
url = {https://www.datanovia.com/blog/dplyr-vs-pandas},
langid = {en}
}