Deduplication and aggregations

kept in this browser

Removing duplicates with distinct, dropDuplicates, or a window function, and aggregating with groupBy/agg, count, approx_count_distinct, avg, describe, and summary.

Checked against its sources on · history

On this page7

What it is

Deduplication removes repeated rows, either entirely or with respect to a subset of columns; aggregation summarizes groups of rows into a single value (count, average, distinct count). These are the operations that turn silver into a trustworthy dataset and gold into numbers the business can use.

Why it exists

Duplicates show up everywhere: a job retry that rewrites the same batch, a source resending an event, CDC with multiple versions of the same record. Silver (see Medallion architecture: bronze, silver, gold) is where you decide the uniqueness key and the rule for which copy to keep. Aggregations, on the other hand, cost a shuffle: knowing when an approximation (approx_count_distinct) is good enough is a cost decision, not just a precision one.

How it works

Deduplication

MethodWhat it comparesWhich row survives
distinct()all columnsany one
dropDuplicates()all columnsany one
dropDuplicates(["k1", "k2"])only the listed columnsany one among those sharing the key
window + row_number()the partitionBy columnsthe one chosen by orderBy

distinct() and dropDuplicates() with no arguments are equivalent to SELECT DISTINCT. With a subset, dropDuplicates doesn’t guarantee which row survives: in a distributed system, “the first one” has no stable meaning. If keeping the most recent record matters, the only deterministic way is a window function with row_number() ordered by timestamp descending and a = 1 filter.

In streaming, dropDuplicates has to keep state for every key it has ever seen; dropDuplicatesWithinWatermark limits that to the watermark window.

Aggregations

groupBy(...) followed by agg(...) with functions from pyspark.sql.functions:

FunctionSQLNotes
count("*")COUNT(*)count("col") ignores nulls
count_distinct("col")COUNT(DISTINCT col)exact, requires a full shuffle; countDistinct is the legacy alias
approx_count_distinct("col", rsd)approx_count_distinct(col)HyperLogLog, default relative error of 5%, much cheaper
avg("col") / mean("col")AVG(col)synonyms
sum, min, max, stddevsame

describe() and summary() are exploratory shortcuts that return a DataFrame: describe computes count, mean, stddev, min, max; summary adds the 25th, 50th, and 75th percentiles and accepts a list of which statistics to compute. Neither is meant for gold tables: they’re for understanding data in a notebook.

groupBy(...).pivot("col") turns a column’s distinct values into columns. Passing the list of values as a second argument avoids an upfront scan.

Example

Silver: keep the latest version of each order. Gold: metrics by channel.

CREATE OR REPLACE TABLE shop.silver.orders AS
SELECT * EXCEPT (rn)
FROM (
  SELECT *,
         ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) AS rn
  FROM shop.bronze.orders_raw
)
WHERE rn = 1;
from pyspark.sql import functions as F
from pyspark.sql.window import Window

raw = spark.read.table("shop.bronze.orders_raw")

w = Window.partitionBy("order_id").orderBy(F.col("updated_at").desc())

latest = (
    raw
    .withColumn("rn", F.row_number().over(w))
    .filter("rn = 1")
    .drop("rn")
)

latest.write.mode("overwrite").saveAsTable("shop.silver.orders")

When the rule is simply “one row per key, doesn’t matter which”:

deduped = raw.dropDuplicates(["order_id"])

Aggregation by channel:

SELECT
  channel,
  COUNT(*)                        AS orders,
  approx_count_distinct(customer_id) AS customers_approx,
  AVG(amount)                     AS avg_amount
FROM shop.silver.orders
GROUP BY channel;
gold = (
    spark.read.table("shop.silver.orders")
    .groupBy("channel")
    .agg(
        F.count("*").alias("orders"),
        F.approx_count_distinct("customer_id").alias("customers_approx"),
        F.avg("amount").alias("avg_amount"),
    )
)

Quick stats in a notebook, and a pivot:

spark.read.table("shop.silver.orders").select("amount").summary("count", "mean", "50%", "max").show()

by_month = (
    spark.read.table("shop.silver.orders")
    .groupBy("channel")
    .pivot("order_month", ["2026-07", "2026-08"])
    .agg(F.sum("amount"))
)

Compared to pandas, groupBy doesn’t produce an index: the result is a plain DataFrame with the grouping columns. And count() with no groupBy is an action that returns an integer, not a column.

Common mistakes

  • Using dropDuplicates(["order_id"]) and assuming it keeps the most recent version: it isn’t guaranteed. You need the window.
  • Running count_distinct on hundreds of millions of rows for a dashboard counter: it costs a full shuffle when approx_count_distinct would be enough.
  • count("col") instead of count("*"): nulls aren’t counted, and the total looks wrong.
  • pivot without a list of values: Spark has to read the data twice, and it isn’t supported in streaming at all.
  • Confusing df.count() (an action, returns a number) with F.count() (an aggregate function, returns a column).

Check yourself

3 questions on what this page says. Pick an answer, then check it.

  1. 1A dashboard counter shows distinct customers over hundreds of millions of rows, and a small error is acceptable. What does the page suggest, and why?
    Show the answer

    C. approx_count_distinct, because the exact count needs a full shuffle and the approximation is much cheaper

    count_distinct is exact but requires a full shuffle. approx_count_distinct trades a small relative error for a much lower cost, which the page treats as a cost decision.

    Read the section again
  2. 2A report pivots orders by month with groupBy("channel").pivot("order_month") and no list of values. What cost does the page attach to leaving the list out?
    Show the answer

    A. Spark has to read the data twice, and the pivot is not supported in streaming at all

    Without the values listed, Spark first needs a pass to find the distinct values, so the data is read twice. The page also notes that pivot is not supported in streaming.

    Read the section again
  3. 3A streaming job removes repeated events with dropDuplicates on an event id and its state keeps growing. What does the page say causes this, and what limits it?
    Show the answer

    A. It must keep every key it has ever seen; dropDuplicatesWithinWatermark limits the state to the watermark window

    In streaming, dropDuplicates must remember all keys seen so far. dropDuplicatesWithinWatermark bounds that memory to the watermark window.

    Read the section again

Where this sits

Resources

3All resources
Report a problem with this page
What kind of problem?

Reports about "Deduplication and aggregations" go to the maintainer, not to a public thread.

Suggest a resource
What kind?

Nothing appears on the site automatically. A person reads every suggestion, checks the link and writes the note that goes with it.