cswork / libraryOpen practice workspace

Articles

R dplyr Interview Questions: Grouping, Joins, Missing Values, and Reproducible Analysis

Practice R dplyr interview questions with worked examples on grouping, duplicate joins, missing values, tie-breaking, and reproducible data analysis.

R dplyr interview questions test whether you can turn messy tables into a trustworthy answer. The important skills are grouping at the right level, joining without changing the meaning of a row, handling missing values deliberately, and leaving enough evidence that another analyst can reproduce your result.

A short pipeline can still be wrong. It might calculate percentages within the wrong group, count one customer twice after a join, or report zero when every observation was missing. Strong answers explain those risks before presenting the final number.

Use the worked examples below to practice predicting outputs before running code. Examples assume local data frames and a modern dplyr version supporting join_by(), .by, and join relationship checks.

What should you explain before writing a pipeline?

State the grain: what does one input row represent, and what should one output row represent? An order line, an order, a customer, and a customer-month are different units. A perfectly valid sum can answer the wrong question if those units get mixed.

Suppose the interviewer asks for revenue per customer. Ask whether the input contains order totals repeated on every line or individual line amounts. Also clarify cancelled orders, refunds, currency, and the date boundary. You do not need a long requirements meeting; one sentence exposing the main ambiguity is enough.

Write down a useful invariant. If you are only adding a customer label, the number of order rows should stay unchanged when the customer table has one row per key. If you aggregate those orders, the total amount should be conserved unless your filtering policy explicitly removes some records. Invariants make debugging faster than staring at a large result.

How do mutate and summarise differ?

Use mutate() to add or change columns while retaining the row-level view. Use summarise() when the answer needs one summary row per group. This choice follows the desired output grain, not which function you remember first.

For a four-row sales table, adding each sale's share of its store total needs four output rows. Reporting total sales per store needs two. The same sum(amount) expression can appear in both operations, but its placement changes the resulting table.

QuestionExpected outputSuitable operation
What percentage of store sales does each row contribute?One row per saleGrouped mutate()
What is each store's total?One row per storeGrouped summarise()
Which stores exceed a total of 100?One row per qualifying storeSummarise, then filter
Which original sales belong to those stores?Original qualifying salesUse qualifying keys to filter

Before moving on, say whether missing amounts are excluded, propagated, or reported separately. The function name does not settle that analytical decision.

Why can grouping silently change a percentage?

Grouping is part of the data frame's state. Consider East sales of 30 and 70 and West sales of 80 and 20. Each store totals 100, while the whole table totals 200.

library(dplyr)
sales <- tibble(
  store = c("East", "East", "West", "West"),
  amount = c(30, 70, 80, 20)
)

sales |>
  group_by(store) |>
  mutate(store_share = amount / sum(amount)) |>
  ungroup() |>
  mutate(overall_share = amount / sum(amount))

The store shares are 30%, 70%, 80%, and 20%. The overall shares are 15%, 35%, 40%, and 10%. Neither set is inherently wrong: they answer different questions. Forgetting ungroup() would make the supposedly overall denominator stay within each store.

Inspect group_vars() when a pipeline inherits a data frame from another function. For an operation that only temporarily needs grouping, .by = store can make that boundary clearer. Avoid combining a pre-grouped input with a per-operation grouping assumption without checking the function's contract.

Does summarise always remove every group?

No. When you group by more than one variable, the returned table can remain grouped. Explicitly choose .groups = "drop" when the next operation should treat the result as an ordinary ungrouped table.

Imagine monthly totals grouped by region and store. After summarising, you want each store's share of all sales. Retained region grouping instead makes the denominator the regional total. The output looks plausible because the percentages still add up neatly within each region.

A useful interview habit is to annotate the grain and grouping separately: “The result has one row per region-store, and I am dropping grouping before calculating a global share.” They are related but not identical properties. A table can contain regional rows without carrying region grouping into later calculations.

Check the documented defaults for the dplyr version in your environment rather than relying on a remembered message. The summarise reference describes grouping control explicitly.

Why did a left join increase the row count?

A left join keeps the left observations, but it can repeat a left row when several right rows match its key. “Keep all left rows” does not mean “return exactly the same number of rows.”

Take two orders: order 101 belongs to customer A and has amount 40; order 102 belongs to B and has amount 60. The label table contains A-Retail, A-Partner, and B-Retail. Joining by customer creates three rows because order 101 matches twice. Summing the joined amounts gives 140 instead of 100.

Investigate the relationship before fixing the result. If each customer should have one label, duplicates may be a data-quality error. If customers legitimately have several labels, the relationship is many-to-many at the business level and revenue attribution needs an explicit policy. Selecting an arbitrary first label would conceal that decision.

For an intended many-orders-to-one-customer lookup, declare relationship = "many-to-one". That checks matching cardinality; it does not prove every order found a customer. Check unmatched keys separately. The join documentation explains these as different concerns.

How do you find missing keys without multiplying rows?

Use an anti_join() to find left-side records without a match. Use a semi_join() when you only need left records that have a match and do not need columns from the right table. This expresses an existence question directly.

unknown_customers <- orders |>
  anti_join(customers, by = join_by(customer_id))

eligible_orders <- orders |>
  semi_join(eligible_customers, by = join_by(customer_id))

Before matching, inspect identifier types and formatting. A customer code with leading zeroes may not be interchangeable with an integer. Trimming whitespace may be appropriate, but changing case or removing punctuation can merge genuinely different identifiers. Document normalization rules rather than applying aggressive cleaning blindly.

Local dplyr joins can match missing keys to other missing keys by default. If an unknown customer must never match another unknown customer, use the appropriate na_matches setting. Do not assume local R behavior and a database backend behave identically.

How should missing values affect totals and filters?

Missing means an observation is unavailable, not necessarily that its value is zero. Consider three regions: A has 10 and 20, B has 0 and 0, and C has two missing values. Their business meanings are “observed total 30,” “observed zero,” and “unknown.”

Removing missing values before summing makes C's computational total zero. That may be useful in some calculations, but it does not establish that the region made no sales. Retain an observed count and make the reporting policy explicit.

region_summary <- sales_by_region |>
  summarise(
    observed = sum(!is.na(amount)),
    total = if (all(is.na(amount))) NA_real_
            else sum(amount, na.rm = TRUE),
    .by = region
  )

Also watch filtering: a condition must be true for a row to survive filter(). If amount > 0 evaluates to missing, that row is not retained. To keep unknown amounts for investigation, include is.na(amount) in the condition instead of assuming they remain automatically.

How do you select a top row when values tie?

First ask whether the requirement is “all joint winners” or “exactly one deterministic winner.” These are different contracts. For all winners, a tie-preserving maximum selection is appropriate. For one winner, provide a secondary ordering key such as an event timestamp and then a unique record ID.

Suppose two products both sold 50 units. Returning whichever happens to occur first in the file is only correct if file order is the stated tie-breaker. Otherwise, the answer may change when ingestion order changes even though the business data has not.

winners <- product_sales |>
  arrange(store, desc(units), product_id) |>
  group_by(store) |>
  slice_head(n = 1) |>
  ungroup()

This example deliberately chooses the smallest product ID among tied unit totals. State how missing unit counts should be treated before sorting. If the input can contain duplicate product IDs within a store, aggregate or add another stable tie-breaker rather than claiming the ordering is unique.

When should you use across instead of repeating code?

Use across() when the same transformation genuinely applies to several selected columns. It can reduce copy-paste mistakes in tasks such as summarising a set of measurement columns, but column selection is part of the logic and deserves review.

For example, where(is.numeric) might include numeric identifiers, coded categories, and weights as well as measurements. Taking the mean of all those columns would be tidy code with an untidy meaning. Prefer an explicit selection when the schema is known and the included columns matter to the analysis.

Name outputs so their provenance is obvious, especially when applying several functions. A column called mean becomes ambiguous after joins; temperature_mean is more informative. Test what happens when an expected column is absent. Silently processing a smaller set may be worse than failing early in a reproducible reporting job.

What changes when the data lives in a database?

Do not assume every local R operation translates to the remote database. A lazy table describes work that may later execute as SQL. Supported functions, missing-value behavior, ordering, and query performance depend on the backend.

Keep filters and column selection close to the remote source when that reduces transferred data, and inspect the generated query when the result or performance is surprising. Collecting millions of rows merely to use a familiar local function can turn an otherwise small analysis into a memory problem.

Check both semantics and cost. An operation might translate successfully while generating a large join or an expensive sort. Explain how you would compare row counts, aggregate totals, and a small known sample between local and remote implementations. This is stronger than saying that database execution is always faster.

How do you make the analysis reproducible?

A reproducible answer includes input assumptions, code, package versions, and checks that fail when those assumptions stop holding. Record the data snapshot or extraction window as well as the script. Yesterday's code applied to today's changing source is not the same experiment.

CheckFailure it can reveal
Customer lookup key is uniqueJoin duplication before it inflates totals
Required identifiers are presentUnmatched or incorrectly normalized records
Observed counts accompany totalsMissing observations disguised as zero
Tie-breaker is explicitDifferent winners after a row-order change
Output grain matches the requestCorrect calculations answering the wrong question

For a final practice exercise, add one duplicated customer key, one missing amount, and one tied product total to a small clean dataset. Predict which check should fail before running the pipeline. Then repair the underlying assumption or reporting rule, not just the visible number. You are ready to explain the solution when you can describe why each retained row belongs in the result and why its denominator is correct.

Sources and Further Reading

Research checked September 24, 2026. Numerical datasets are illustrative. Confirm package versions and backend behavior before applying examples to production data.