answer_run <- run('
sales
then keep where ([revenue] > 150)
')Appendix I — The same task, five ways
If you write dplyr, tidyr, pandas, polars or data.table today, this appendix is the one that answers the question you actually have: can it do what I already do? Each entry states a task in English. It gives god’s sentence in all three of its spellings, then what you would write in each of four neighbors. The first of the four is dplyr or tidyr, depending on the task. The answer is shown once, because all seven produce the same table and the page refuses to build if any of them does not.
god’s three come first: the text form that run takes, then R, then Python. They are one sentence, and this page is where that is checked rather than claimed: all three execute and all three are compared against each other before any neighbor is looked at.
That last part is the difference between this appendix and a comparison table. Nothing here is asserted. Every cell runs when the book is built, and the tables are compared value by value; a disagreement stops the build rather than reaching you. The code in each neighbor’s column is written the way somebody fluent in it would write it, not generated. What you are reading is a fair comparison rather than god’s own output wearing five costumes.
This page is not a claim that god does everything these libraries do. It does not, on purpose, and what this grammar does not do is the list: regular expressions, arbitrary functions in a cell, and nine more, each with the exit named. What this page claims is narrower and more useful: the ordinary work, the operations that fill most days, can all be said in one small vocabulary that reads the same in two languages.
The tables are the book’s usual cast, described in the preface. sales is fifteen rows of orders.
I.1 Keep the rows above a value
Which orders brought in more than 150?
answer <- sales |> keep(revenue > 150) |> collect()answer_py = sales >> keep(col.revenue > 150)by_dplyr <- sales |> filter(revenue > 150)by_pandas = sales[sales.revenue > 150]by_polars = pl_sales.filter(pl.col("revenue") > 150)by_dt <- as.data.table(sales)[revenue > 150]| date | region | product | quantity | revenue | cost |
|---|---|---|---|---|---|
| 2026-01-09 | East | Widget | 8 | 200 | 80 |
| 2026-01-26 | West | Gadget | 5 | 300 | 200 |
| 2026-03-03 | East | Doohickey | 5 | 200 | 125 |
| 2026-05-06 | North | Gadget | 4 | 240 | 160 |
Seven spellings of one idea, and three of them are god’s. Its four neighbors each read as an operation on a frame; god’s reads as a sentence, and reads the same whichever of the three you write it in.
I.2 Total a column for each group
What did each product earn in total, and across how many orders?
answer_run <- run('
sales
then summarize [earned] as total([revenue]), [orders] as row_count() by [product]
')answer <- sales |>
summarize(earned = total(revenue), orders = row_count(), by = product) |>
collect()answer_py = sales >> summarize(earned = total(col.revenue), orders = row_count(),
by = col.product)by_dplyr <- sales |>
group_by(product) |>
summarise(earned = sum(revenue), orders = n(), .groups = "drop")by_pandas = (sales
.groupby("product")
.agg(earned=("revenue", "sum"), orders=("revenue", "size"))
.reset_index())by_polars = pl_sales.group_by("product").agg(
pl.col("revenue").sum().alias("earned"),
pl.len().alias("orders"))by_dt <- as.data.table(sales)[, .(earned = sum(revenue), orders = .N), by = product]| product | earned | orders |
|---|---|---|
| Doohickey | 400 | 3 |
| Gadget | 720 | 4 |
| Sprocket | 240 | 2 |
| Widget | 700 | 6 |
Note how each library spells count the rows: n(), size, pl.len(), .N. god says row_count(), which is the same word in R and in Python.
I.3 Turn rows into columns
Each student answered two questions. Put one row per student, one column per question.
answer_run <- run('
marks
then widen name [question], value [mark] by [student]
')answer <- marks |> widen(name = question, value = mark, by = student) |> collect()answer_py = marks >> widen(name = col.question, value = col.mark,
by = col.student)by_dplyr <- marks |> pivot_wider(names_from = question, values_from = mark)by_pandas = (marks
.pivot(index="student", columns="question", values="mark")
.reset_index()
.rename_axis(None, axis=1))by_polars = pl_marks.pivot(on="question", index="student", values="mark")by_dt <- dcast(as.data.table(marks), student ~ question, value.var = "mark")| student | q1 | q2 |
|---|---|---|
| ann | 1 | 2 |
| bob | 4 | 5 |
Five names for one idea: pivot_wider, pivot, pivot, dcast, and widen. Only one of them says in the verb what happens to the table.
The by is written out here, and it did not have to be. Without it, god decides that the rows going together are the ones agreeing on every other column, and then says so. The answer arrives with the assumption printed above it, naming student and offering the phrasing that would have removed the doubt. That is the behavior, not a warning about it. It is written out above so that all three of god’s spellings on this page are the same sentence.
I.4 Keep the rows matching a word
Which orders came from the West?
answer_run <- run('
sales
then keep where ([region] is "West")
')answer <- sales |> keep(region == "West") |> collect()answer_py = sales >> keep(col.region == "West")by_dplyr <- sales |> filter(region == "West")by_pandas = sales[sales.region == "West"]by_polars = pl_sales.filter(pl.col("region") == "West")by_dt <- as.data.table(sales)[region == "West"]| date | region | product | quantity | revenue | cost |
|---|---|---|---|---|---|
| 2025-11-03 | West | Widget | 4 | 100 | 40 |
| 2025-12-05 | West | Doohickey | 3 | 120 | 75 |
| 2026-01-26 | West | Gadget | 5 | 300 | 200 |
| 2026-04-11 | West | Widget | 2 | 50 | 20 |
| 2026-07-08 | West | Sprocket | 6 | 90 | 72 |
| 2026-10-02 | West | Widget | 5 | 125 | 50 |
god writes is in the text form and == in both host languages, because that is what each host already means by it.
I.5 Choose some columns
Just the product and what it earned.
answer_run <- run('
sales
then pick [product, revenue]
')answer <- sales |> pick(product, revenue) |> collect()answer_py = sales >> pick(col.product, col.revenue)by_dplyr <- sales |> select(product, revenue)by_pandas = sales[["product", "revenue"]]by_polars = pl_sales.select("product", "revenue")by_dt <- as.data.table(sales)[, .(product, revenue)]| product | revenue |
|---|---|
| Widget | 100 |
| Gadget | 120 |
| Doohickey | 120 |
| Widget | 150 |
| Widget | 200 |
| Gadget | 300 |
| Doohickey | 80 |
| Doohickey | 200 |
| Widget | 50 |
| Gadget | 240 |
| Sprocket | 150 |
| Sprocket | 90 |
| Widget | 75 |
| Gadget | 60 |
| Widget | 125 |
All four neighbors name the columns they want, and so does god. pick and select are words; [[ ]] and .( ) are punctuation.
I.6 Drop a column
Everything except what it cost.
answer_run <- run('
sales
then pick all_but [cost]
')answer <- sales |> pick(all_but(cost)) |> collect()answer_py = sales >> pick(all_but(col.cost))by_dplyr <- sales |> select(-cost)by_pandas = sales.drop(columns=["cost"])by_polars = pl_sales.drop("cost")by_dt <- as.data.table(sales)[, !"cost"]| date | region | product | quantity | revenue |
|---|---|---|---|---|
| 2025-11-03 | West | Widget | 4 | 100 |
| 2025-11-17 | East | Gadget | 2 | 120 |
| 2025-12-05 | West | Doohickey | 3 | 120 |
| 2025-12-22 | North | Widget | 6 | 150 |
| 2026-01-09 | East | Widget | 8 | 200 |
| 2026-01-26 | West | Gadget | 5 | 300 |
| 2026-02-14 | North | Doohickey | 2 | 80 |
| 2026-03-03 | East | Doohickey | 5 | 200 |
| 2026-04-11 | West | Widget | 2 | 50 |
| 2026-05-06 | North | Gadget | 4 | 240 |
| 2026-06-19 | East | Sprocket | 10 | 150 |
| 2026-07-08 | West | Sprocket | 6 | 90 |
| 2026-08-24 | North | Widget | 3 | 75 |
| 2026-09-15 | East | Gadget | 1 | 60 |
| 2026-10-02 | West | Widget | 5 | 125 |
all_but is the same word wherever it stands, where the neighbors reach for a minus sign, a drop, and a !.
I.7 Add a computed column
What did each order make?
answer_run <- run('
sales
then add [margin] as ([revenue] - [cost])
then pick [product, margin]
')answer <- sales |> add(margin = revenue - cost) |> pick(product, margin) |> collect()answer_py = (sales >> add(margin = col.revenue - col.cost)
>> pick(col.product, col.margin))by_dplyr <- sales |> mutate(margin = revenue - cost) |> select(product, margin)by_pandas = sales.assign(margin=sales.revenue - sales.cost)[["product", "margin"]]by_polars = (pl_sales
.with_columns((pl.col("revenue") - pl.col("cost")).alias("margin"))
.select("product", "margin"))by_dt <- as.data.table(sales)[, .(product, margin = revenue - cost)]| product | margin |
|---|---|
| Widget | 60 |
| Gadget | 40 |
| Doohickey | 45 |
| Widget | 90 |
| Widget | 120 |
| Gadget | 100 |
| Doohickey | 30 |
| Doohickey | 75 |
| Widget | 30 |
| Gadget | 80 |
| Sprocket | 30 |
| Sprocket | 18 |
| Widget | 45 |
| Gadget | 20 |
| Widget | 75 |
mutate, assign, with_columns, .( ), and add. Only the last one is a word a reader who has never seen it would guess correctly.
I.8 Average a column for each group
What is the typical order in each region?
answer_run <- run('
sales
then summarize [typical] as average([revenue]) by [region]
')answer <- sales |> summarize(typical = average(revenue), by = region) |> collect()answer_py = sales >> summarize(typical = average(col.revenue), by = col.region)by_dplyr <- sales |>
group_by(region) |>
summarise(typical = mean(revenue), .groups = "drop")by_pandas = (sales
.groupby("region")
.agg(typical=("revenue", "mean"))
.reset_index())by_polars = pl_sales.group_by("region").agg(
pl.col("revenue").mean().alias("typical"))by_dt <- as.data.table(sales)[, .(typical = mean(revenue)), by = region]| region | typical |
|---|---|
| East | 146.0000 |
| North | 136.2500 |
| West | 130.8333 |
by is an argument in god rather than a step, so grouping cannot outlive the sentence that asked for it.
I.9 Sort and take the top few
The five biggest orders.
answer_run <- run('
sales
then sort [revenue] descending, [product]
then take 5
then pick [product, revenue]
')answer <- sales |>
sort(descending(revenue), product) |>
take(5) |>
pick(product, revenue) |>
collect()answer_py = (sales >> sort(descending(col.revenue), col.product) >> take(5)
>> pick(col.product, col.revenue))by_dplyr <- sales |> arrange(desc(revenue), product) |> head(5) |> select(product, revenue)by_pandas = (sales
.sort_values(["revenue", "product"], ascending=[False, True])
.head(5)[["product", "revenue"]])by_polars = (pl_sales
.sort(["revenue", "product"], descending=[True, False])
.head(5)
.select("product", "revenue"))by_dt <- head(as.data.table(sales)[order(-revenue, product)], 5)[, .(product, revenue)]| product | revenue |
|---|---|
| Gadget | 300 |
| Gadget | 240 |
| Doohickey | 200 |
| Widget | 200 |
| Sprocket | 150 |
Every library here spells reverse the order differently: desc, ascending=False, descending=True, a minus sign. god says descending.
The second sort key is not decoration, and this page needed it to build. Two orders earned 150, so the fifth row of a top five is a tie, and which of them wins is whatever each library’s sort happened to do. pandas sorts with an unstable algorithm by default, so it answered one way on one machine and the other way on another. The comparison here refused to build until the question was made a fair one. A top five over a column with repeats is not a settled answer, which is the law this grammar is built on. The repair is the same in all seven spellings: name a second key.
I.10 Take the rows at the far end
The three smallest orders, smallest last.
answer_run <- run('
sales
then sort [revenue] descending
then take_last 3
then pick [product, revenue]
')answer <- sales |> sort(descending(revenue)) |> take_last(3) |> pick(product, revenue) |> collect()answer_py = (sales >> sort(descending(col.revenue)) >> take_last(3)
>> pick(col.product, col.revenue))by_dplyr <- sales |> arrange(desc(revenue)) |> tail(3) |> select(product, revenue)by_pandas = (sales
.sort_values("revenue", ascending=False)
.tail(3)[["product", "revenue"]])by_polars = (pl_sales
.sort("revenue", descending=True)
.tail(3)
.select("product", "revenue"))by_dt <- tail(as.data.table(sales)[order(-revenue)], 3)[, .(product, revenue)]| product | revenue |
|---|---|
| Widget | 75 |
| Gadget | 60 |
| Widget | 50 |
This is the entry that produced a word. polars and pandas both call this end tail. Before take_last, god’s only spelling was to sort the other way and take the first three, which returns the same rows backwards.
I.11 Bring in a column from another table
Who makes each product?
answer_run <- run('
sales
then join products by [product]
then pick [product, maker, revenue]
')answer <- sales |>
join(products, by = product) |>
pick(product, maker, revenue) |>
collect()answer_py = (sales >> join(products, by = col.product)
>> pick(col.product, col.maker, col.revenue))by_dplyr <- sales |>
left_join(products, by = "product") |>
select(product, maker, revenue)by_pandas = sales.merge(products, on="product", how="left")[["product", "maker", "revenue"]]by_polars = (pl_sales
.join(pl_products, on="product", how="left")
.select("product", "maker", "revenue"))by_dt <- merge(as.data.table(sales), as.data.table(products),
by = "product", all.x = TRUE)[, .(product, maker, revenue)]| product | maker | revenue |
|---|---|---|
| Widget | Acme | 100 |
| Gadget | Globex | 120 |
| Doohickey | Initech | 120 |
| Widget | Acme | 150 |
| Widget | Acme | 200 |
| Gadget | Globex | 300 |
| Doohickey | Initech | 80 |
| Doohickey | Initech | 200 |
| Widget | Acme | 50 |
| Gadget | Globex | 240 |
| Widget | Acme | 75 |
| Gadget | Globex | 60 |
| Widget | Acme | 125 |
| Sprocket | NA | 150 |
| Sprocket | NA | 90 |
god has one join and an unmatched word for whose rows survive, where the neighbors have a verb for each kind or an argument for it. Note which way the default falls: god keeps the rows it started with, so Sprocket survives with no maker rather than disappearing. Writing this entry with inner_join is what the page’s own comparison caught, on the row count.
I.12 Ask whether a row has a partner
Which orders are for a product the catalog lists?
answer_run <- run('
sales
then keep where matching(products, by [product])
then pick [product, revenue]
')answer <- sales |>
keep(matching(products, by = product)) |>
pick(product, revenue) |>
collect()answer_py = (sales >> keep(matching(products, by = col.product))
>> pick(col.product, col.revenue))by_dplyr <- sales |> semi_join(products, by = "product") |> select(product, revenue)by_pandas = sales[sales["product"].isin(products["product"])][["product", "revenue"]]by_polars = (pl_sales
.join(pl_products.select("product"), on="product", how="semi")
.select("product", "revenue"))by_dt <- as.data.table(sales)[product %chin% products$product, .(product, revenue)]| product | revenue |
|---|---|
| Widget | 100 |
| Gadget | 120 |
| Doohickey | 120 |
| Widget | 150 |
| Widget | 200 |
| Gadget | 300 |
| Doohickey | 80 |
| Doohickey | 200 |
| Widget | 50 |
| Gadget | 240 |
| Widget | 75 |
| Gadget | 60 |
| Widget | 125 |
matching is a question rather than a join, so it cannot multiply rows. A merge makes no such promise: when the other table repeats a key, the rows multiply.
I.13 Turn columns into rows
Two measures per order, one row each.
answer_run <- run('
sales
then pick [product, revenue, cost]
then lengthen [revenue, cost] as name [measure], value [amount]
')answer <- sales |>
pick(product, revenue, cost) |>
lengthen(revenue, cost, name = measure, value = amount) |>
collect()answer_py = (sales >> pick(col.product, col.revenue, col.cost)
>> lengthen(col.revenue, col.cost, name = col.measure,
value = col.amount))by_dplyr <- sales |>
select(product, revenue, cost) |>
pivot_longer(c(revenue, cost), names_to = "measure", values_to = "amount")by_pandas = (sales[["product", "revenue", "cost"]]
.melt(id_vars="product", var_name="measure", value_name="amount"))by_polars = (pl_sales
.select("product", "revenue", "cost")
.unpivot(index="product", variable_name="measure", value_name="amount"))by_dt <- melt(as.data.table(sales)[, .(product, revenue, cost)],
id.vars = "product", variable.name = "measure",
value.name = "amount", variable.factor = FALSE)| product | measure | amount |
|---|---|---|
| Doohickey | cost | 50 |
| Doohickey | cost | 75 |
| Doohickey | cost | 125 |
| Doohickey | revenue | 80 |
| Doohickey | revenue | 120 |
| Doohickey | revenue | 200 |
| Gadget | cost | 40 |
| Gadget | cost | 80 |
| Gadget | cost | 160 |
| Gadget | cost | 200 |
| Gadget | revenue | 60 |
| Gadget | revenue | 120 |
| Gadget | revenue | 240 |
| Gadget | revenue | 300 |
| Sprocket | cost | 72 |
| Sprocket | cost | 120 |
| Sprocket | revenue | 90 |
| Sprocket | revenue | 150 |
| Widget | cost | 20 |
| Widget | cost | 30 |
| Widget | cost | 40 |
| Widget | cost | 50 |
| Widget | cost | 60 |
| Widget | cost | 80 |
| Widget | revenue | 50 |
| Widget | revenue | 75 |
| Widget | revenue | 100 |
| Widget | revenue | 125 |
| Widget | revenue | 150 |
| Widget | revenue | 200 |
lengthen and widen are one idea read in both directions. tidyr’s pair says so too; god’s says it in one word each.
I.14 Choose between values
Label each order by size.
answer_run <- run('
sales
then add [size] as when(([revenue] >= 200), "big", ([revenue] >= 100), "medium", otherwise "small")
then pick [product, revenue, size]
')answer <- sales |>
add(size = when(revenue >= 200, "big", revenue >= 100, "medium", otherwise = "small")) |>
pick(product, revenue, size) |>
collect()answer_py = (sales >> add(size = when(col.revenue >= 200, "big",
col.revenue >= 100, "medium",
otherwise = "small"))
>> pick(col.product, col.revenue, col.size))by_dplyr <- sales |>
mutate(size = case_when(revenue >= 200 ~ "big",
revenue >= 100 ~ "medium",
.default = "small")) |>
select(product, revenue, size)import numpy as np
by_pandas = sales.assign(size=np.select(
[sales.revenue >= 200, sales.revenue >= 100],
["big", "medium"], default="small"))[["product", "revenue", "size"]]by_polars = (pl_sales
.with_columns(pl.when(pl.col("revenue") >= 200).then(pl.lit("big"))
.when(pl.col("revenue") >= 100).then(pl.lit("medium"))
.otherwise(pl.lit("small")).alias("size"))
.select("product", "revenue", "size"))by_dt <- as.data.table(sales)[, .(product, revenue,
size = fifelse(revenue >= 200, "big", fifelse(revenue >= 100, "medium", "small")))]| product | revenue | size |
|---|---|---|
| Widget | 100 | medium |
| Gadget | 120 | medium |
| Doohickey | 120 | medium |
| Widget | 150 | medium |
| Widget | 200 | big |
| Gadget | 300 | big |
| Doohickey | 80 | small |
| Doohickey | 200 | big |
| Widget | 50 | small |
| Gadget | 240 | big |
| Sprocket | 150 | medium |
| Sprocket | 90 | small |
| Widget | 75 | small |
| Gadget | 60 | small |
| Widget | 125 | medium |
polars needs pl.lit around every one of those words, because a bare string there is read as a column name. That is a mistake a printed example can carry without looking wrong, and it is why this page runs everything it shows.
I.15 Number the rows in order
Where does each order place, biggest first?
answer_run <- run('
sales
then add [place] as rank([revenue] descending)
then pick [product, revenue, place]
')answer <- sales |>
add(place = rank(descending(revenue))) |>
pick(product, revenue, place) |>
collect()answer_py = (sales >> add(place = rank(descending(col.revenue)))
>> pick(col.product, col.revenue, col.place))by_dplyr <- sales |>
mutate(place = min_rank(desc(revenue))) |>
select(product, revenue, place)by_pandas = sales.assign(
place=sales.revenue.rank(ascending=False, method="min").astype("int64")
)[["product", "revenue", "place"]]by_polars = (pl_sales
.with_columns(pl.col("revenue").rank(method="min", descending=True)
.cast(pl.Int64).alias("place"))
.select("product", "revenue", "place"))by_dt <- as.data.table(sales)[, .(product, revenue,
place = frank(-revenue, ties.method = "min"))]| product | revenue | place |
|---|---|---|
| Gadget | 300 | 1 |
| Gadget | 240 | 2 |
| Widget | 200 | 3 |
| Doohickey | 200 | 3 |
| Widget | 150 | 5 |
| Sprocket | 150 | 5 |
| Widget | 125 | 7 |
| Gadget | 120 | 8 |
| Doohickey | 120 | 8 |
| Widget | 100 | 10 |
| Sprocket | 90 | 11 |
| Doohickey | 80 | 12 |
| Widget | 75 | 13 |
| Gadget | 60 | 14 |
| Widget | 50 | 15 |
rank is the one window word that needs no sort in front of it, because its argument already says what the order is.
I.16 Total as you go down the rows
How does revenue accumulate, smallest order first?
answer_run <- run('
sales
then sort [revenue]
then add [so_far] as running_total([revenue])
then pick [revenue, so_far]
')answer <- sales |>
sort(revenue) |>
add(so_far = running_total(revenue)) |>
pick(revenue, so_far) |>
collect()answer_py = (sales >> sort(col.revenue)
>> add(so_far = running_total(col.revenue))
>> pick(col.revenue, col.so_far))by_dplyr <- sales |>
arrange(revenue) |>
mutate(so_far = cumsum(revenue)) |>
select(revenue, so_far)_sorted = sales.sort_values("revenue", kind="stable")
by_pandas = _sorted.assign(so_far=_sorted.revenue.cumsum())[["revenue", "so_far"]]by_polars = (pl_sales
.sort("revenue")
.with_columns(pl.col("revenue").cum_sum().alias("so_far"))
.select("revenue", "so_far"))by_dt <- as.data.table(sales)[order(revenue), .(revenue, so_far = cumsum(revenue))]| revenue | so_far |
|---|---|
| 50 | 50 |
| 60 | 110 |
| 75 | 185 |
| 80 | 265 |
| 90 | 355 |
| 100 | 455 |
| 120 | 575 |
| 120 | 695 |
| 125 | 820 |
| 150 | 970 |
| 150 | 1120 |
| 200 | 1320 |
| 200 | 1520 |
| 240 | 1760 |
| 300 | 2060 |
god refuses running_total without a sort in front of it. The other four compute on whatever order the rows happen to be in, which is an answer that can change between runs.
I.17 Trim the spaces off a text column
The names arrived padded.
answer_run <- run('
messy
then add [name] as trim([raw])
then pick [name, n]
')answer <- messy |> add(name = trim(raw)) |> pick(name, n) |> collect()answer_py = messy >> add(name = trim(col.raw)) >> pick(col.name, col.n)by_dplyr <- messy |> mutate(name = trimws(raw)) |> select(name, n)by_pandas = messy.assign(name=messy.raw.str.strip())[["name", "n"]]by_polars = (pl_messy
.with_columns(pl.col("raw").str.strip_chars().alias("name"))
.select("name", "n"))by_dt <- as.data.table(messy)[, .(name = trimws(raw), n)]| name | n |
|---|---|
| ann marie | 7 |
| bob | 99 |
trimws, str.strip, str.strip_chars, trimws, trim. The same operation, and the only agreement is the two R libraries borrowing base R’s trimws.
I.18 Change a column’s case
The region in capital letters.
answer_run <- run('
sales
then add [loud] as upper([region])
then pick [region, loud]
')answer <- sales |> add(loud = upper(region)) |> pick(region, loud) |> collect()answer_py = (sales >> add(loud = upper(col.region))
>> pick(col.region, col.loud))by_dplyr <- sales |> mutate(loud = toupper(region)) |> select(region, loud)by_pandas = sales.assign(loud=sales.region.str.upper())[["region", "loud"]]by_polars = (pl_sales
.with_columns(pl.col("region").str.to_uppercase().alias("loud"))
.select("region", "loud"))by_dt <- as.data.table(sales)[, .(region, loud = toupper(region))]| region | loud |
|---|---|
| West | WEST |
| East | EAST |
| West | WEST |
| North | NORTH |
| East | EAST |
| West | WEST |
| North | NORTH |
| East | EAST |
| West | WEST |
| North | NORTH |
| East | EAST |
| West | WEST |
| North | NORTH |
| East | EAST |
| West | WEST |
upper and lower are the two everyone has. god spells them the shortest way that still says which direction.
I.19 Take a piece of a text column
The part of each product name before the letter d.
answer_run <- run('
sales
then add [head] as split_text([product], "d", 1)
then pick [product, head]
')answer <- sales |> add(head = split_text(product, "d", 1)) |> pick(product, head) |> collect()answer_py = (sales >> add(head = split_text(col.product, "d", 1))
>> pick(col.product, col.head))by_dplyr <- sales |>
mutate(head = vapply(strsplit(product, "d", fixed = TRUE), `[`, "", 1L)) |>
select(product, head)by_pandas = sales.assign(
head=sales["product"].str.split("d").str[0])[["product", "head"]]by_polars = (pl_sales
.with_columns(pl.col("product").str.split("d").list.get(0).alias("head"))
.select("product", "head"))by_dt <- as.data.table(sales)[, .(product,
head = vapply(strsplit(product, "d", fixed = TRUE), `[`, "", 1L))]| product | head |
|---|---|
| Widget | Wi |
| Gadget | Ga |
| Doohickey | Doohickey |
| Widget | Wi |
| Widget | Wi |
| Gadget | Ga |
| Doohickey | Doohickey |
| Doohickey | Doohickey |
| Widget | Wi |
| Gadget | Ga |
| Sprocket | Sprocket |
| Sprocket | Sprocket |
| Widget | Wi |
| Gadget | Ga |
| Widget | Wi |
god counts pieces from 1, and says which piece it wants in the call. The two R spellings need a vapply around a list, which is the shape this grammar is trying to spare you.
I.20 Put text back together
Label each order with its region and product.
answer_run <- run('
sales
then add [label] as join_text([region], " ", [product])
then pick [label, revenue]
')answer <- sales |>
add(label = join_text(region, " ", product)) |>
pick(label, revenue) |>
collect()answer_py = (sales >> add(label = join_text(col.region, " ", col.product))
>> pick(col.label, col.revenue))by_dplyr <- sales |>
mutate(label = paste0(region, " ", product)) |>
select(label, revenue)by_pandas = sales.assign(
label=sales.region + " " + sales["product"])[["label", "revenue"]]by_polars = (pl_sales
.with_columns(pl.concat_str([pl.col("region"), pl.lit(" "), pl.col("product")])
.alias("label"))
.select("label", "revenue"))by_dt <- as.data.table(sales)[, .(label = paste0(region, " ", product), revenue)]| label | revenue |
|---|---|
| West Widget | 100 |
| East Gadget | 120 |
| West Doohickey | 120 |
| North Widget | 150 |
| East Widget | 200 |
| West Gadget | 300 |
| North Doohickey | 80 |
| East Doohickey | 200 |
| West Widget | 50 |
| North Gadget | 240 |
| East Sprocket | 150 |
| West Sprocket | 90 |
| North Widget | 75 |
| East Gadget | 60 |
| West Widget | 125 |
join_text exists because split_text does: taking text apart and not being able to put it back is an asymmetry no law asked for.
I.21 Pull the year out of a date
The year and the month of each order.
answer_run <- run('
sales
then add [d] as to_date([date])
then add [y] as year([d]), [m] as month([d])
then pick [date, y, m]
')answer <- sales |>
add(d = to_date(date)) |>
add(y = year(d), m = month(d)) |>
pick(date, y, m) |>
collect()answer_py = (sales >> add(d = to_date(col.date))
>> add(y = year(col.d), m = month(col.d))
>> pick(col.date, col.y, col.m))by_dplyr <- sales |>
mutate(d = as.Date(date),
y = as.numeric(format(d, "%Y")),
m = as.numeric(format(d, "%m"))) |>
select(date, y, m)_d = pd.to_datetime(sales.date)
by_pandas = sales.assign(y=_d.dt.year, m=_d.dt.month)[["date", "y", "m"]]by_polars = (pl_sales
.with_columns(pl.col("date").str.to_date().alias("d"))
.with_columns(pl.col("d").dt.year().alias("y"),
pl.col("d").dt.month().alias("m"))
.select("date", "y", "m"))by_dt <- as.data.table(sales)[, {
d <- as.Date(date)
.(date, y = as.numeric(format(d, "%Y")), m = as.numeric(format(d, "%m")))
}]| date | y | m |
|---|---|---|
| 2025-11-03 | 2025 | 11 |
| 2025-11-17 | 2025 | 11 |
| 2025-12-05 | 2025 | 12 |
| 2025-12-22 | 2025 | 12 |
| 2026-01-09 | 2026 | 1 |
| 2026-01-26 | 2026 | 1 |
| 2026-02-14 | 2026 | 2 |
| 2026-03-03 | 2026 | 3 |
| 2026-04-11 | 2026 | 4 |
| 2026-05-06 | 2026 | 5 |
| 2026-06-19 | 2026 | 6 |
| 2026-07-08 | 2026 | 7 |
| 2026-08-24 | 2026 | 8 |
| 2026-09-15 | 2026 | 9 |
| 2026-10-02 | 2026 | 10 |
Every library here needs the text turned into a date first. god never applies to_date on your behalf: year asked of text is a refusal that names the conversion.
I.22 Fill the holes in a column
The catalog does not list every product. Say so rather than leaving a blank.
answer_run <- run('
sales
then join products by [product]
then fill_missing [maker] as "unlisted"
then pick [product, maker]
')answer <- sales |>
join(products, by = product) |>
fill_missing(maker = "unlisted") |>
pick(product, maker) |>
collect()answer_py = (sales >> join(products, by = col.product)
>> fill_missing(maker = "unlisted")
>> pick(col.product, col.maker))by_dplyr <- sales |>
left_join(products, by = "product") |>
mutate(maker = coalesce(maker, "unlisted")) |>
select(product, maker)by_pandas = (sales
.merge(products, on="product", how="left")
.fillna({"maker": "unlisted"})[["product", "maker"]])by_polars = (pl_sales
.join(pl_products, on="product", how="left")
.with_columns(pl.col("maker").fill_null("unlisted"))
.select("product", "maker"))by_dt <- merge(as.data.table(sales), as.data.table(products),
by = "product", all.x = TRUE)[
, .(product, maker = fifelse(is.na(maker), "unlisted", maker))]| product | maker |
|---|---|
| Widget | Acme |
| Gadget | Globex |
| Doohickey | Initech |
| Widget | Acme |
| Widget | Acme |
| Gadget | Globex |
| Doohickey | Initech |
| Doohickey | Initech |
| Widget | Acme |
| Gadget | Globex |
| Widget | Acme |
| Gadget | Globex |
| Widget | Acme |
| Sprocket | unlisted |
| Sprocket | unlisted |
fill_missing refuses a filler of the wrong kind: a number into a text column is a refusal rather than a silent coercion.
I.23 Drop the rows with a hole
Only the orders whose product the catalog lists.
answer_run <- run('
sales
then join products by [product]
then drop_missing [maker]
then pick [product, maker, revenue]
')answer <- sales |>
join(products, by = product) |>
drop_missing(maker) |>
pick(product, maker, revenue) |>
collect()answer_py = (sales >> join(products, by = col.product)
>> drop_missing(col.maker)
>> pick(col.product, col.maker, col.revenue))by_dplyr <- sales |>
left_join(products, by = "product") |>
filter(!is.na(maker)) |>
select(product, maker, revenue)by_pandas = (sales
.merge(products, on="product", how="left")
.dropna(subset=["maker"])[["product", "maker", "revenue"]])by_polars = (pl_sales
.join(pl_products, on="product", how="left")
.drop_nulls("maker")
.select("product", "maker", "revenue"))by_dt <- merge(as.data.table(sales), as.data.table(products),
by = "product", all.x = TRUE)[!is.na(maker),
.(product, maker, revenue)]| product | maker | revenue |
|---|---|---|
| Widget | Acme | 100 |
| Gadget | Globex | 120 |
| Doohickey | Initech | 120 |
| Widget | Acme | 150 |
| Widget | Acme | 200 |
| Gadget | Globex | 300 |
| Doohickey | Initech | 80 |
| Doohickey | Initech | 200 |
| Widget | Acme | 50 |
| Gadget | Globex | 240 |
| Widget | Acme | 75 |
| Gadget | Globex | 60 |
| Widget | Acme | 125 |
drop_missing names the column it is judging. A bare dropna() judges every column at once, which is a different question wearing the same name.
I.24 Remove exact repeats
Which regions appear at all?
answer_run <- run('
sales
then pick [region]
then drop_duplicates
')answer <- sales |> pick(region) |> drop_duplicates() |> collect()answer_py = sales >> pick(col.region) >> drop_duplicates()by_dplyr <- sales |> select(region) |> distinct()by_pandas = sales[["region"]].drop_duplicates()by_polars = pl_sales.select("region").unique()by_dt <- unique(as.data.table(sales)[, .(region)])| region |
|---|
| East |
| North |
| West |
distinct, drop_duplicates, unique, unique, drop_duplicates. god took the longest of the five names because it is the one that says what happens to the table rather than what the table becomes.
I.25 Make the missing combinations appear
Every region crossed with every product, including the pairs nobody sold.
answer_run <- run('
sales
then summarize [earned] as total([revenue]) by [region, product]
then add_combinations [region, product]
then fill_missing [earned] as 0
')answer <- sales |>
summarize(earned = total(revenue), by = c(region, product)) |>
add_combinations(region, product) |>
fill_missing(earned = 0) |>
collect()answer_py = (sales >> summarize(earned = total(col.revenue),
by = [col.region, col.product])
>> add_combinations(col.region, col.product)
>> fill_missing(earned = 0))by_dplyr <- sales |>
group_by(region, product) |>
summarise(earned = sum(revenue), .groups = "drop") |>
complete(region, product, fill = list(earned = 0))_g = sales.groupby(["region", "product"], as_index=False).agg(
earned=("revenue", "sum"))
_full = pd.MultiIndex.from_product(
[sorted(sales.region.unique()), sorted(sales["product"].unique())],
names=["region", "product"])
by_pandas = (_g.set_index(["region", "product"])
.reindex(_full, fill_value=0)
.reset_index())_g = pl_sales.group_by("region", "product").agg(
pl.col("revenue").sum().alias("earned"))
by_polars = (pl_sales.select("region").unique()
.join(pl_sales.select("product").unique(), how="cross")
.join(_g, on=["region", "product"], how="left")
.with_columns(pl.col("earned").fill_null(0)))by_dt <- as.data.table(sales)[, .(earned = sum(revenue)), by = .(region, product)][
CJ(region = unique(sales$region), product = unique(sales$product)),
on = .(region, product)][is.na(earned), earned := 0][]| region | product | earned |
|---|---|---|
| East | Doohickey | 200 |
| East | Gadget | 180 |
| East | Sprocket | 150 |
| East | Widget | 200 |
| North | Doohickey | 80 |
| North | Gadget | 240 |
| North | Widget | 225 |
| West | Doohickey | 120 |
| West | Gadget | 300 |
| West | Sprocket | 90 |
| West | Widget | 275 |
| North | Sprocket | 0 |
This is the entry where the five diverge most, and the reason is that the absent rows have to be made rather than found. god splits it in two on purpose: add_combinations makes the rows and fill_missing says what a new one holds. Whether an absent pair means zero is a question about your data rather than about the verb.
This page compared 168 answers against god’s while it was built, and every one of them agreed.
Whichever of the five you arrived with, the sentence you would write in god is the same sentence, in either language. It was checked against your own tool’s answer before this page reached you.