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_run <- run('
  sales
    then keep where ([revenue] > 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.