SaveMyCert
Log in
5 of 5 free questions left today·for 30 a day
Data Transformation and Modeling

Deduplication, Aggregation and Basic Spark Tuning in Databricks (Data Engineer Associate)

18 min readDatabricks DEA · Data Transformation and ModelingUpdated

Deduplication, aggregation and basic tuning are the Spark skills a Databricks data engineer uses to turn bronze data into clean, correct silver and gold tables and to make those jobs run efficiently. This lesson covers removing exact and key-based duplicates in PySpark and SQL (including keeping the latest row per key and deduplicating streams), how count, approximate count distinct, mean and summary behave with nulls, and the four tuning settings the exam guide names: spark.sql.shuffle.partitions, spark.default.parallelism, driver and executor memory, and spark.sql.autoBroadcastJoinThreshold, plus how to re-measure performance after changing them.

What you’ll learn
  • Choose between distinct(), dropDuplicates() and a key subset by deciding which columns define a duplicate
  • Keep exactly one deterministic row per key with row_number() in PySpark or QUALIFY in Databricks SQL, including tie-breakers
  • Deduplicate a streaming DataFrame with bounded state using a watermark, dropDuplicates or dropDuplicatesWithinWatermark
  • Predict count, countDistinct, approx_count_distinct, avg and summary results when columns contain nulls, and roll up distinct counts with HLL sketches
  • Explain what spark.sql.shuffle.partitions, spark.default.parallelism, spark.executor.memory, spark.driver.memory and spark.sql.autoBroadcastJoinThreshold control and which can be changed in a session
  • Re-measure a tuning change fairly with explain(), noop writes and Spark UI stage metrics

Removing duplicate rows: distinct(), dropDuplicates() and a key subset

Deduplication starts with one decision: which columns define a duplicate. distinct() and dropDuplicates() with no arguments treat two rows as duplicates only when every column is equal, which is right for exact copies such as a bronze load that ran twice. dropDuplicates(["col1", "col2"]) compares only the listed columns and keeps one full row per combination.

CallDuplicate meansKeepsTypical use
df.distinct() / df.dropDuplicates() / SELECT DISTINCT *All columns equalOne copy of each distinct row, all columnsExact copies from a rerun or a double load; no column identifies a row
df.dropDuplicates(["order_id", "line_no"])Listed columns equalOne arbitrary full row per keyCopies that differ only in metadata (file name, load time) and any copy is acceptable
df.select("order_id").distinct()Selected columns equalOnly the selected columnsA list of keys, not a cleaned table

Two mistakes come from choosing the wrong column set. A subset that is too narrow (for example order_id alone when an order has several lines, or when rows sharing an id are legitimate corrections) merges rows that are genuinely different. A subset that is too wide (including load_ts or ingest_file) keeps copies that differ only in metadata. The right subset is the natural key of the entity. dropna() is not a deduplication tool: it removes rows with nulls.

In SQL, SELECT DISTINCT * is the whole-row equivalent. GROUP BY on a key with SELECT * is not valid deduplication, because every non-grouped column must be aggregated.

Keeping the latest row per key with row_number() and QUALIFY

When the requirement is a specific row per key, such as the current price for each product, use a window function, not dropDuplicates. dropDuplicates(subset) keeps one row per key but makes no promise about which one; sorting the DataFrame first does not help, because the deduplication happens after a shuffle that does not preserve that order.

from pyspark.sql import Window
from pyspark.sql import functions as F

w = (Window.partitionBy("product_id")
     .orderBy(F.col("effective_at").desc(),
              F.col("price_version").desc()))

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

The pattern is: partition by the key, order by the recency column descending, number the rows with row_number(), keep row 1. Alternatives fall short in specific ways:

  • groupBy(key).agg(max("effective_at")) returns the key and the timestamp only; the other columns are lost unless you join back.
  • dropDuplicates(["product_id", "effective_at"]) keeps one row per key and timestamp, so a product with three price changes still has three rows.
  • rank() and dense_rank() give tied rows the same number, so filtering on 1 keeps every tied row and the key appears twice.

Ties and repeatable results

If two rows in a partition share the same ordering value, row_number() numbers them in whatever order they arrive, which can change between runs. To make the result deterministic, add a unique tie-breaker after the recency column, such as a version number. The rule is that the column carrying the business meaning of "latest" comes first in the ORDER BY and the unique column comes last, where it only decides between rows that are otherwise tied.

The same pattern in Databricks SQL: QUALIFY

Databricks SQL supports QUALIFY, a clause that filters on the result of window functions, so the keep-the-latest pattern fits in one SELECT without a subquery or CTE:

SELECT *
FROM product_prices
QUALIFY ROW_NUMBER() OVER (
  PARTITION BY product_id
  ORDER BY effective_at DESC, price_version DESC) = 1

The reason QUALIFY exists is evaluation order. A query evaluates WHERE, then GROUP BY, then HAVING, and only then computes window functions. A window function therefore cannot appear in WHERE or HAVING; QUALIFY runs after the windows are computed. The same tie rules apply as in PySpark: use ROW_NUMBER() (not RANK()) when the key must appear exactly once, and add a unique tie-breaker last in the ORDER BY when ties are possible.

Deduplicating a streaming DataFrame with a watermark

On a Structured Streaming DataFrame, dropDuplicates works across micro-batches by remembering every key it has seen in the state store. Without a watermark that state grows forever. A watermark (withWatermark("event_ts", "10 minutes")) lets Spark discard old state, but which deduplication call you use decides whether it can.

PatternBounded state?Matches duplicates that
dropDuplicates(["event_id"]), no watermarkNo — every key kept forevershare event_id
withWatermark("event_ts", …).dropDuplicates(["event_id"])No — the key has no event-time column, so Spark cannot tell an entry's ageshare event_id
withWatermark("event_ts", …).dropDuplicates(["event_id", "event_ts"])Yesshare event_id and the same event_ts (exact copies)
withWatermark("event_ts", …).dropDuplicatesWithinWatermark(["event_id"]) (Spark 3.5+)Yes — a key is dropped from state once the watermark passesshare event_id, even with different event_ts, while the gap between them is within the watermark delay

So: if duplicates are exact copies with the same timestamp, include the event-time column in the dropDuplicates key. If retries carry a new timestamp (a producer resending an event), use dropDuplicatesWithinWatermark with a watermark delay longer than the maximum gap between duplicates. Deduplicating inside foreachBatch only removes duplicates within one micro-batch.

Counting rows, values and distinct values: how nulls change the answer

Spark's counting functions differ in how they treat nulls, and that is the source of most "the numbers don't match" puzzles.

ExpressionCountsNulls
df.count(), F.count("*"), COUNT(*)RowsEvery row counts
F.count("email")Non-null values of the columnSkipped
F.countDistinct("email") (= count_distinct, COUNT(DISTINCT email))Distinct non-null values, exactSkipped
F.countDistinct("a", "b")Distinct pairsA row with a null in any listed column is skipped
df.select("a").distinct().count()Distinct rowsNull is treated as a value: all nulls form one row

Examples: 500 rows where 60 have a null phone and the other 440 phones include 400 distinct numbers give count("*") = 500, count("phone") = 440, countDistinct("phone") = 400. A status column with three values plus some nulls gives 4 from select("status").distinct().count() and 3 from countDistinct("status"). When a null must count as its own value (a web session with no referrer is still a distinct page/referrer pair), use select(...).distinct().count().

To count missing values, subtract: F.count("*") - F.count("amount"). F.count(F.isnull("amount")) looks right but counts every row, because isnull returns true or false, never null, and count only skips nulls. (Summing a 0/1 flag such as F.sum(F.col("amount").isNull().cast("int")) also works.)

Approximate distinct counts and mergeable HLL sketches

approx_count_distinct estimates the number of distinct values with the HyperLogLog++ algorithm. An exact distinct count has to track every distinct value; HLL++ keeps a small fixed-size sketch per group, so it is much cheaper on billions of rows when a few percent of error is acceptable.

  • The rsd argument is the maximum relative standard deviation, as a fraction. The default is 0.05 (about 5%). Lower values (for example rsd=0.01) give a tighter estimate and a bigger sketch; higher values loosen it.
  • The Spark documentation notes that for rsd below 0.01 an exact count_distinct is usually more efficient.
  • Accuracy depends only on rsd. Shuffle partitions, caching or cluster size change speed, not the error.

Distinct counts do not add up

Distinct counts are not additive. A user active on five days appears in five daily counts, so summing daily unique users overstates weekly unique users; taking the maximum understates it; and counting the distinct daily totals measures nothing useful. This is true for exact and approximate counts alike. To roll up without rescanning raw events, store a sketch instead of a number. Databricks SQL and PySpark provide:

FunctionDoes
hll_sketch_agg(col)Builds a HyperLogLog sketch (binary) per group, e.g. per day
hll_union_agg(sketch)Merges stored sketches across groups, e.g. seven days into a week
hll_sketch_estimate(sketch)Turns a sketch into an approximate count

A weekly figure from daily sketches is hll_sketch_estimate(hll_union_agg(daily_sketch)), grouped by week. Any period can be rolled up the same way.

Profiling with describe() and summary()

describe() and summary() both return a quick statistical profile as a DataFrame with a summary column holding the statistic name and one column per profiled data column. They differ in what the arguments mean and what they compute.

df.describe(...)df.summary(...)
Default statisticscount, mean, stddev, min, maxcount, mean, stddev, min, 25%, 50%, 75%, max
Arguments areColumn names to profileStatistics to compute, e.g. "count", "min", "max", "50%" (any percentile, approximate), plus "count_distinct" and "approx_count_distinct"
PercentilesNeverYes (approximate)

So df.summary("count", "min", "max", "50%") returns exactly those four statistics; to limit columns, select them first: df.select("amount", "qty").summary(...). Because statistics are rows of the output, you filter the summary column rather than selecting statistic columns. printSchema() and dtypes describe types, not values. In a notebook, display() output also offers a data profile, but describe()/summary() are the DataFrame API answers.

Mean, nulls and several aggregates in one groupBy

avg (alias mean) and the other aggregate functions skip nulls: avg("rating") divides the sum of non-null ratings by the number of non-null ratings, which is the same as sum("rating") / count("rating"). Whether that is right depends on what a null means in the business rule.

Null meansCorrect expressionWrong choices
Value not supplied; leave it out (a skipped rating)F.avg("rating")fillna(0) counts skips as zero; sum / count("*") uses all rows as the denominator
Zero (opened the app, watched nothing)F.avg(F.coalesce("minutes", F.lit(0))) or fillna firstPlain avg leaves those rows out and overstates the average

Be careful with dropna() without a subset: it removes rows with a null in any column, so an unrelated nullable column (a missing comment) can silently remove valid values from the average. Use dropna(subset=["rating"]) or rely on the function's null skipping.

One groupBy, many aggregates

groupBy() returns grouped data; agg() takes any number of Column expressions and computes them all in one pass:

(df.groupBy("region")
   .agg(F.count("*").alias("orders"),
        F.avg("amount").alias("avg_amount"),
        F.max("amount").alias("max_amount"),
        F.approx_count_distinct("customer_id")
          .alias("customers")))

Avoid the dictionary form for several functions on one column: {"amount": "avg", "amount": "max"} is a Python dict, which silently keeps only the last value for a repeated key. Aggregating before grouping (df.agg(...).groupBy(...)) collapses the DataFrame to one row first; adding the measured column to groupBy changes the grain; and separate aggregations joined back, or windows followed by deduplication, do the same work several times.

The basic Spark tuning parameters

Four groups of settings cover the basic tuning the exam guide names. Each controls something different, and a change only helps if it targets what the Spark UI shows is wrong.

SettingControlsDefaultChangeable in a session?
spark.sql.shuffle.partitionsNumber of partitions (tasks) after every DataFrame/SQL shuffle: groupBy, sort-merge joins, windows, distinct200Yes — spark.conf.set or SQL SET, takes effect immediately
spark.default.parallelismDefault partitions for RDD operations (parallelize, reduceByKey or RDD join without a partition count)Based on total coresNo — read once when the SparkContext starts, so set it in the cluster's Spark config
spark.executor.memory / spark.driver.memoryJVM heap of each executor / of the driverDerived from node type on DatabricksNo — fixed when the processes start
spark.sql.autoBroadcastJoinThresholdLargest estimated table size Spark broadcasts automatically in a join; -1 disables automatic broadcasts10 MBYes

Shuffle partitions

Each shuffle partition becomes one task. Too many partitions for small data means hundreds of tasks processing a few kilobytes each, so scheduling overhead dominates: lower the value. Too few for big data means huge tasks: 1.5 TB over the default 200 partitions puts about 7.5 GB in each task, which cannot fit in executor memory and spills to disk. Raise the value so each partition holds roughly 100–200 MB (several thousand partitions for terabytes). Spill with even key distribution is a partition-size problem; spill concentrated in a few tasks is skew, which is a different fix.

Adaptive query execution (AQE), on by default, coalesces small adjacent post-shuffle partitions and splits skewed outlier partitions in joins. It does not split partitions that are all evenly too large, so for big aggregations the starting value of spark.sql.shuffle.partitions still matters: set it high enough and let AQE merge any that turn out small. Databricks also accepts spark.sql.shuffle.partitions=auto (auto-optimized shuffle), which lets the platform choose the number from the data.

spark.default.parallelism does not touch DataFrames

Raising spark.default.parallelism does not change the task count of a DataFrame groupBy or join; those read spark.sql.shuffle.partitions. A job that mixes an RDD reduceByKey and a DataFrame groupBy needs both settings to control both stages. File-scan settings such as spark.sql.files.maxPartitionBytes size input partitions, not shuffle partitions.

Driver and executor memory

Executors do the distributed work; the driver plans it and receives anything returned to the notebook. collect() and toPandas() copy every row into the driver, so a multi-gigabyte toPandas() fails with a driver out-of-memory error while executors sit idle; that is governed by spark.driver.memory (on Databricks, by the driver node type). Executor spill and executor OOM in shuffles relate to spark.executor.memory and to partition size. Because memory sizes the JVM processes, spark.conf.set("spark.executor.memory", ...) fails in Spark 3 with an error that the config cannot be modified; set it in the cluster's Spark config or pick a larger node type, then restart. When a restart is not allowed, the session-level lever is more shuffle partitions, so each task holds less data. On serverless compute, Databricks manages memory and most Spark settings itself and only a small set of session configs can be changed.

Broadcast joins and autoBroadcastJoinThreshold

When one side of a join is estimated to be smaller than spark.sql.autoBroadcastJoinThreshold (10 MB by default), Spark plans a broadcast hash join: it collects that side on the driver and sends a copy to every executor, so the large side is not shuffled. Otherwise it typically plans a sort-merge join, which shuffles both sides. (Join types and the broadcast() hint as a join technique are covered with joins in the cleaning, joining and reshaping lesson; here the focus is the threshold as a tuning parameter.)

  • Raise the threshold above a dimension's size (for example to 32 MB for a 25 MB table) to get automatic broadcasts with no code change; it can be set in a job cluster's Spark config.
  • Too high is dangerous: if the threshold is raised to several gigabytes, a multi-gigabyte fact table qualifies, the driver collects it and runs out of memory. Executor memory and shuffle partitions do not fix that. Bring the threshold back near the default and use explicit broadcast() hints for the small tables that must be broadcast.
  • -1 disables automatic broadcasts (hints still apply). spark.sql.broadcastTimeout only limits how long a broadcast may take, and spark.sql.join.preferSortMergeJoin (true by default) chooses between shuffle-based join types; neither turns broadcasting off.

AQE can change the join at runtime

The static planner uses estimated sizes, and a selective filter often does not shrink the estimate much, so a filtered large dimension may be planned as a sort-merge join. With AQE on, Spark measures the real size after the filtering stage and, if it is small enough, switches to a broadcast hash join. On Databricks this runtime decision uses its own setting, spark.databricks.adaptive.autoBroadcastJoinThreshold (30 MB by default). That is why a benchmark that needs a guaranteed sort-merge join turns AQE off as well as setting the static threshold to -1.

Re-measuring performance after a tuning change

A tuning change is only finished when you have re-measured it fairly. The method:

  1. Change one setting at a time, so you know which change made the difference.
  2. Check the plan before running with explain(): BroadcastHashJoin confirms a broadcast, SortMergeJoin means it was not chosen. Reading the config value back only proves the setting is set, not that the planner used it. With AQE on, the output is wrapped in AdaptiveSparkPlan isFinalPlan=false: it is the initial plan, and the plan that actually ran appears in the SQL / DataFrame tab of the Spark UI after execution.
  3. Run the whole transformation. count() lets Spark prune columns and skip work, and show() computes only a few rows, so neither reflects the real job. A write with df.write.format("noop").mode("overwrite").save() executes every transformation without the cost of writing real output.
  4. Compare like with like. Repeat runs can be faster because of disk caching, so do not keep only the fastest of several timings.
  5. Read the stage metrics. In the Spark UI, the stage details page shows per-task duration, shuffle read and write, spill (memory) and spill (disk), with percentiles. Compare the affected stage before and after. The Environment tab shows settings, the Storage tab shows cached data and the Executors tab shows resources; none of them proves the spill is gone.

Tip. This part of Section 3 is tested with short scenarios built around a symptom and a requirement: copies from a rerun versus legitimate corrections, a latest-row query that must be repeatable, a stream whose state keeps growing, counts that disagree because of nulls, a rollup of unique users, a profile that needs specific statistics, a Spark UI showing tiny tasks, spill or a driver out-of-memory error, a broadcast that did or did not happen, and a benchmark that needs a fair measurement. Expect PySpark and SQL snippets in the options. Several options are usually real Spark settings or functions, and the stated constraint (no code change, no cluster restart, one row per key, nulls counted as zero) decides between them, so know what each setting controls, its default, whether it can be changed in a session, and how each function treats nulls.

Key takeaways
  • distinct() and dropDuplicates() with no arguments compare every column; dropDuplicates(subset) keeps one arbitrary full row per key, so the subset must be the entity's natural key.
  • To keep a specific row per key, use row_number() over a window partitioned by the key and ordered by recency, keep row 1, and add a unique tie-breaker last; rank() and dense_rank() keep ties.
  • In Databricks SQL, QUALIFY filters on window functions in a single SELECT; WHERE and HAVING cannot, because windows are computed after them.
  • Streaming deduplication needs a watermark and either the event-time column in the dropDuplicates key or dropDuplicatesWithinWatermark, or its state grows forever.
  • count(*) counts rows; count(col), countDistinct and avg skip nulls; distinct() treats null as a value; multi-column countDistinct skips rows with any null.
  • approx_count_distinct uses HyperLogLog++ with rsd 0.05 by default; distinct counts are not additive, so store hll_sketch_agg sketches and merge them with hll_union_agg for rollups.
  • summary() takes statistic names and adds approximate percentiles; describe() takes column names and returns count, mean, stddev, min and max.
  • spark.sql.shuffle.partitions (default 200) sets DataFrame/SQL shuffle tasks and can be set per session; spark.default.parallelism only affects RDDs; driver and executor memory are fixed at launch.
  • autoBroadcastJoinThreshold (10 MB, -1 disables) trades shuffles for driver and executor memory; AQE can still switch to a broadcast at runtime using its own Databricks threshold.
  • Re-measure one change at a time on the full job (a noop write, not count() or show()) and compare the Spark UI stage's spill, shuffle and task times.

Frequently asked questions

What is the difference between distinct() and dropDuplicates() in PySpark?

With no arguments they are the same: both remove rows that are equal in every column. dropDuplicates() also accepts a list of columns, in which case it treats rows as duplicates when only those columns match and keeps one full row per combination, with no guarantee about which row. To keep a specific row, such as the latest per key, use row_number() over a window instead.

Why does countDistinct return a different number from distinct().count()?

countDistinct skips null values, while distinct() treats null as a value and keeps one null row. On a column with some nulls, distinct().count() is therefore one higher. With several columns, countDistinct skips any row that has a null in one of them, so use select(cols).distinct().count() when a null should count as its own value.

How do I keep the latest row per key in Databricks SQL?

Use QUALIFY ROW_NUMBER() OVER (PARTITION BY key ORDER BY updated_at DESC, unique_id DESC) = 1 in a single SELECT. QUALIFY filters after window functions are computed, which WHERE and HAVING cannot do. ROW_NUMBER keeps exactly one row per key, and the unique column after the timestamp breaks ties so reruns return the same row.

When should I use approx_count_distinct instead of an exact distinct count?

Use approx_count_distinct when the data is large and a small error is acceptable, such as unique visitors on a dashboard. It uses HyperLogLog++ with a default relative standard deviation of 5%, which you can tighten with the rsd argument. For very tight accuracy (rsd below 0.01) or when exact figures are required, use count_distinct.

Can I add up daily unique user counts to get a weekly figure?

No. Distinct counts are not additive: a user active on several days appears in each day's count, so the sum overstates unique users. Either recount over the raw events for the week or store a HyperLogLog sketch per day with hll_sketch_agg and combine the sketches with hll_union_agg, then read the result with hll_sketch_estimate.

What does spark.sql.shuffle.partitions do, and what value should I use?

It sets how many partitions, and therefore tasks, every DataFrame or SQL shuffle produces, such as a groupBy or sort-merge join; the default is 200. Aim for shuffle partitions of roughly 100 to 200 MB: lower the value for small data with many tiny tasks, raise it for large data that spills. Adaptive query execution can merge small partitions but does not split evenly large ones.

Why does spark.conf.set fail for spark.executor.memory?

Executor and driver memory size the JVM processes, so they are read once when those processes start. In Spark 3, changing them with spark.conf.set raises an error that the config cannot be modified. Set them in the cluster's Spark configuration or choose a node type with more memory, then restart the cluster. Session settings such as spark.sql.shuffle.partitions can be changed with spark.conf.set.

How do I confirm a tuning change actually helped?

Change one setting at a time, check the plan with explain() (for example BroadcastHashJoin versus SortMergeJoin), then run the full transformation, for instance with a noop-format write, rather than count() or show(), which skip work. Compare the affected stage in the Spark UI before and after: task duration, shuffle read and write, and memory and disk spill.

Source

This lesson covers the "Data Transformation and Modeling" domain of the official Databricks DEA exam guide. Vendors revise their guides — check the source for the current version.

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?