Home / DE Professional practice test / Data Transformation, Cleansing, and Quality

Free · 8 questions with explanations

Data Transformation, Cleansing, and Quality: Databricks Data Engineer Professional Practice Questions

Exam-style questions on Data Transformation, Cleansing, and Quality. Pick your answer, then open the explanation to see why it's right — and why the other options are wrong.

1 Data Transformation, Cleansing, and Quality

A Delta table sales contains category, product_id, and revenue. A report needs the top 3 products per category by total revenue, but must include ties (for example, if the 3rd and 4th products have the same revenue, both should appear). Which approach best satisfies the requirement?

  1. AAggregate revenue per (category, product_id), then compute row_number() over PARTITION BY category ORDER BY total_revenue DESC and filter row_number <= 3.
  2. BSkip window functions: GROUP BY category and use ORDER BY total_revenue DESC LIMIT 3 to automatically return the top 3 per category.
  3. CAggregate revenue per (category, product_id), then compute dense_rank() over PARTITION BY category ORDER BY total_revenue DESC and filter dense_rank <= 3.
  4. DUse rank() and filter rank <= 3; rank() never produces gaps, so it always returns exactly three rows per category.
Show answer & explanation

Correct answer: C

WHY C: dense_rank() assigns the same rank to ties and does not create gaps, so filtering dense_rank <= 3 includes all products tied within the top-3 ranks. WHY NOT A: row_number() breaks ties arbitrarily, which can exclude tied items that should be included. WHY NOT B: LIMIT 3 applies to the whole result set, not per category. WHY NOT D: rank() *does* produce gaps when there are ties, so rank <= 3 can return fewer than 3 ranks worth of data depending on ties; it also doesn’t guarantee exactly three rows.

2 Data Transformation, Cleansing, and Quality

A PySpark job defines the following window spec for cumulative metrics: w = Window.partitionBy('customer_id').orderBy('event_time') and then computes F.sum('amount').over(w) without calling rowsBetween or rangeBetween. Which window frame is used by default, and what is the correct way to compute a rolling sum over the last 7 rows per customer (row-based, not value/range-based)?

  1. ADefault: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. Rolling 7 rows: keep the same spec; Spark automatically limits to the last 7 rows when an ORDER BY is present.
  2. BDefault: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW when an ordering is defined. Rolling 7 rows: w7 = w.rowsBetween(-6, 0) and then F.sum('amount').over(w7).
  3. CDefault: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW when an ordering is defined. Rolling 7 rows: w7 = w.rangeBetween(-6, 0) and then F.sum('amount').over(w7).
  4. DDefault: no frame is defined; Spark throws an error unless you specify either rowsBetween or rangeBetween. Rolling 7 rows: w7 = w.rowsBetween(-7, -1).
Show answer & explanation

Correct answer: B

WHY B: In PySpark, when ordering is defined, the default frame is a growing range frame from unbounded preceding to current row (rangeFrame, UNBOUNDED PRECEDING to CURRENT ROW). To get the last 7 rows (row-count based), you must explicitly specify a row frame like rowsBetween(-6, 0). WHY NOT A: With an ORDER BY, Spark does not default to a full-partition, unbounded-following frame, and it never auto-limits to 7 rows. WHY NOT C: rangeBetween(-6, 0) is range/value-based and depends on the ordering column’s value semantics; it’s not “last 7 rows”. WHY NOT D: Spark does not require an explicit frame; it chooses a default based on whether ordering is defined.

3 Data Transformation, Cleansing, and Quality

A data engineer runs a nightly MERGE INTO target USING updates ON target.id = updates.id that performs WHEN MATCHED THEN UPDATE and WHEN NOT MATCHED THEN INSERT. The job intermittently fails with an error stating that the MERGE matched multiple source rows for a single target row and cannot deterministically update it. The updates source is a raw CDC feed that can contain several changes for the same id within one batch. Which fix correctly resolves the non-deterministic MERGE while applying only the most recent change per id?

  1. AChange the join condition to ON target.id = updates.id AND updates.is_latest = true and rely on Delta to ignore non-latest duplicates; the extra predicate guarantees only one source row per target row participates in the match without pre-aggregating the source.
  2. BAdd WHEN MATCHED AND updates.op = 'U' THEN UPDATE plus a second WHEN MATCHED AND updates.op = 'D' THEN DELETE; splitting the matched clause by operation type makes the multi-match deterministic because each matched row now falls into exactly one clause.
  3. CSet spark.databricks.delta.merge.repartitionBeforeWrite.enabled to true, which repartitions the source by id so duplicate source rows for the same id are processed by a single task and automatically collapsed into the last write.
  4. DBefore the MERGE, collapse the source to one row per id by keeping the latest change (e.g., a row_number() window on id ordered by the change timestamp desc, filtered to 1), then MERGE the deduplicated source so each target row matches at most one source row.
  5. EWrap the MERGE in SET spark.databricks.delta.merge.enableLowShuffle = true; low-shuffle merge tolerates multiple matching source rows by applying them in ingestion order, so the multi-match error no longer occurs.
Show answer & explanation

Correct answer: D

WHY D: Delta's MERGE requires that each target row match at most one source row for a deterministic UPDATE. With a CDC feed carrying multiple changes per id, the correct fix is to pre-reduce the source to one row per key (keep the latest via a window function) before the MERGE, guaranteeing a single match per target. WHY NOT B: Splitting the matched clause by operation type does not reduce the number of source rows that match a target — if two source rows both have op='U' for the same id, they still both match and the error persists. WHY NOT C: There is no configuration that makes MERGE silently collapse multiple matching source rows into a 'last write'; MERGE explicitly errors on multi-match to avoid non-determinism. repartitionBeforeWrite only affects output file layout. WHY NOT A: is_latest = true only works if the source already guarantees exactly one latest row per id; if the raw CDC feed does not carry a reliable single-latest flag, duplicates can still match. Pre-deduplication is the robust fix. WHY NOT E: enableLowShuffle is a performance optimization for MERGE; it does not relax the single-match determinism requirement, so the error would remain.

4 Data Transformation, Cleansing, and Quality

A data engineer must transform a long-format sales(region, product_category, amount) table into a wide report with one row per region and one summed-amount column per product_category. New product categories are added frequently, and the engineer wants the transformation to include every category present in the data at run time without editing the query each time a category is added. Which Spark approach produces the correct dynamic wide output?

  1. AUse df.groupBy('region').rollup('product_category').sum('amount'); rollup produces one subtotal column per distinct category value, giving the same wide layout as a pivot while also computing grand totals.
  2. BUse df.groupBy('region').pivot('product_category', ['electronics','apparel','home']).sum('amount') with the full category list hardcoded; an explicit list is required because pivot cannot determine the columns otherwise, and unlisted categories are summed into an other column.
  3. CUse df.groupBy('region', 'product_category').agg(sum('amount')) and then collect() the distinct categories on the driver to build the wide schema with a Python loop of withColumn calls, because pivot cannot create columns dynamically.
  4. DUse a SQL GROUP BY region with a CASE WHEN product_category = ... sum for each category; this is the only way to guarantee all categories appear because CASE expressions are evaluated per row regardless of new categories.
  5. EUse df.groupBy('region').pivot('product_category').sum('amount'); calling pivot without an explicit value list triggers a distinct scan of product_category, so all categories present at run time become columns automatically.
Show answer & explanation

Correct answer: E

WHY E: pivot('product_category') without an explicit value list makes Spark run a distinct scan of the pivot column, so every category present at run time becomes a column automatically — exactly the dynamic behavior required (at the cost of one extra scan). WHY NOT B: Hardcoding the value list is more efficient but defeats the requirement to pick up new categories automatically, and pivot does NOT bucket unlisted values into an other column — they are simply excluded. WHY NOT C: pivot CAN create columns dynamically; a driver-side collect plus manual withColumn loop is unnecessary and does not scale. WHY NOT D: A per-category CASE WHEN must enumerate categories in the query, so it must be edited whenever a new category appears — the opposite of dynamic. WHY NOT A: rollup computes hierarchical subtotals as extra rows, not one column per category; it does not reshape long to wide.

5 Data Transformation, Cleansing, and Quality

A data engineer must deduplicate a Delta table of change events so that, for each account_id, only the record with the latest event_ts is kept; if two records share the same account_id and event_ts, the one with the higher ingest_seq wins, and exactly one row per account_id must remain. The engineer wants a single-pass, deterministic transformation. Which approach is correct and deterministic?

  1. AGroup by account_id and select max_by(struct(event_ts, ingest_seq), event_ts); max_by on event_ts alone returns the latest event, and ties are broken arbitrarily by Spark, which is acceptable because tied timestamps represent the same logical record.
  2. BDefine w = Window.partitionBy('account_id').orderBy(col('event_ts').desc(), col('ingest_seq').desc()), add row_number().over(w) as rn, and filter rn == 1; the compound ordering makes the winner deterministic and guarantees exactly one row per account_id.
  3. CDefine w = Window.partitionBy('account_id').orderBy(col('event_ts').desc()), add rank().over(w) as rk, and filter rk == 1; rank returns the top record per account and resolves ties by keeping all tied rows so no data is lost.
  4. DUse dropDuplicates(['account_id']) after sorting the DataFrame globally by event_ts descending and ingest_seq descending; the prior global sort guarantees dropDuplicates keeps the first (latest) row it encounters per account_id.
  5. EUse df.groupBy('account_id').agg(first('event_ts', ignorenulls=True)) with the DataFrame pre-sorted by ingest_seq; first after sorting returns the latest event deterministically because Spark preserves sort order through the aggregation.
Show answer & explanation

Correct answer: B

WHY B: A window partitioned by account_id ordered by event_ts descending then ingest_seq descending, with row_number() filtered to 1, keeps exactly one deterministic winner per account and correctly applies the tie-breaker. This is the canonical deterministic dedup pattern. WHY NOT A: max_by(event_ts) breaks ties arbitrarily, so the required ingest_seq tie-breaker is not applied deterministically; the assumption that tied timestamps are the same record contradicts the stated rule. WHY NOT C: rank() (and dense_rank()) return ALL rows tied at the top, so an account with tied event_ts yields more than one row, violating the 'exactly one row' requirement. WHY NOT D: dropDuplicates does not guarantee it keeps the first row of a prior sort — Spark does not preserve a global sort order through the shuffle that dropDuplicates performs, so the result is non-deterministic. WHY NOT E: Spark aggregations do not preserve input sort order, so first() after a sort is non-deterministic; first is not a reliable 'latest' selector.

6 Data Transformation, Cleansing, and Quality

A data engineer builds a Lakeflow Spark Declarative Pipeline (Delta Live Tables) that loads a Silver transactions table. The requirement is: rows that fail a critical data-quality rule (amount > 0) must NOT enter the Silver table, but they must also NOT be lost — they must be routed to a separate quarantine table for investigation, and the pipeline must keep running rather than aborting the update. Which implementation meets all of these requirements?

  1. AApply @dlt.expect_or_drop('valid_amount', 'amount > 0') on the Silver table to remove failing rows, and define a second table that reads the same source with the inverted predicate (amount <= 0 OR amount IS NULL) as its own expectation-free quarantine table, so valid rows flow to Silver and invalid rows are retained separately while the pipeline continues.
  2. BApply @dlt.expect_or_fail('valid_amount', 'amount > 0') to the Silver table so that failing rows halt the update immediately; then re-run the pipeline pointing at only the failed rows to populate a quarantine table from the pipeline event log.
  3. CApply @dlt.expect('valid_amount', 'amount > 0') to the Silver table; the warn-level expectation keeps the row out of Silver and Databricks automatically writes every warned row to a system-managed _quarantine table with no additional code.
  4. DAdd a CONSTRAINT valid_amount CHECK (amount > 0) ON VIOLATION DROP ROW to the Silver table definition and enable pipelines.quarantine.autoCapture, a pipeline configuration flag that mirrors every dropped row into a quarantine Delta table automatically.
  5. EUse @dlt.expect_all_or_drop with the single rule amount > 0; because expect_all_or_drop both drops and captures, the dropped rows are emitted as a change-data-feed stream that a downstream table reads to materialize the quarantine table without a second source scan.
Show answer & explanation

Correct answer: A

WHY A: The standard quarantine pattern in Lakeflow/DLT is: put expect_or_drop (or an ON VIOLATION DROP ROW constraint) on the good table so failing rows are removed but the pipeline keeps running, and define a separate table that reads the same source with the inverted predicate to retain the bad rows. Valid rows reach Silver, invalid rows are preserved in quarantine, and nothing is lost. WHY NOT B: expect_or_fail aborts the pipeline update on the first violation, violating the 'keep running' requirement, and the event log records metrics, not the full offending rows for re-materialization. WHY NOT C: expect (warn) does NOT drop the row — warned rows still flow into the table — and there is no automatic system _quarantine table. WHY NOT D: ON VIOLATION DROP ROW correctly drops without failing, but pipelines.quarantine.autoCapture is not a real configuration flag; capturing dropped rows requires the explicit inverted-predicate table. WHY NOT E: expect_all_or_drop applies multiple expectations and drops on failure; it does not emit dropped rows as a CDF stream, so the quarantine table is not auto-populated this way.

7 Data Transformation, Cleansing, and Quality

A data engineer writes unit tests for a transformation function enrich(df) that adds derived columns. The test must assert that the transformed DataFrame equals an expected DataFrame regardless of row order, that the two schemas match exactly (names, types, nullability), and it should run on a local test session without a full production cluster. Which testing approach uses the built-in PySpark utilities correctly?

  1. ACall assertDataFrameEqual(actual, expected) from pyspark.testing, which compares both data and schema and, by default, treats the DataFrames as equal irrespective of row ordering; optionally pair it with assertSchemaEqual(actual.schema, expected.schema) for an explicit schema-only check.
  2. BCollect both DataFrames to Python lists and assert sorted(actual.collect()) == sorted(expected.collect()); this is required because assertDataFrameEqual compares only schemas, not row data, so data equality must be checked manually.
  3. CUse actual.exceptAll(expected).count() == 0 as the sole assertion; exceptAll handles both schema and data comparison including nullability, so no separate schema assertion is needed and row order is inherently ignored.
  4. DUse assertDataFrameEqual(actual, expected, checkRowOrder=True) as the default call, because the utility fails unless rows are in identical order; to ignore order you must first .orderBy() both DataFrames identically since the function has no order-insensitive mode.
  5. ERegister both DataFrames as temp views and run SELECT * FROM actual MINUS SELECT * FROM expected; an empty result proves equality of data, schema, and nullability, which is the officially recommended PySpark unit-test pattern.
Show answer & explanation

Correct answer: A

WHY A: assertDataFrameEqual from pyspark.testing compares both data and schema and is order-insensitive by default (row order does not affect equality), and assertSchemaEqual provides an explicit schema comparison including names, types, and nullability. Both run on a local SparkSession, matching the exam's stated testing utilities. WHY NOT B: assertDataFrameEqual compares data, not just schema, so the manual sorted-collect workaround is unnecessary and would not check nullability. WHY NOT C: exceptAll compares rows but is asymmetric per call, ignores column-name/nullability differences when types align positionally, and a single exceptAll does not fully validate schema; it is not the built-in equality assertion. WHY NOT D: checkRowOrder defaults to False (order-insensitive); the utility does have an order-insensitive mode, so the premise is wrong. WHY NOT E: A SQL MINUS check is not the recommended PySpark unit-testing utility and does not reliably assert nullability or column names.

8 Data Transformation, Cleansing, and Quality

A classic job uses Auto Loader to ingest JSON files where new fields can appear without notice and some fields occasionally arrive with type mismatches. The pipeline must keep running (no manual restarts), and unexpected fields must not be silently dropped. Which configuration best satisfies these requirements while minimizing custom state handling?

  1. AUse Auto Loader with cloudFiles.schemaEvolutionMode='rescue' (and a persistent cloudFiles.schemaLocation + checkpointLocation). Unexpected columns and type/case mismatches are captured in the rescued data column (for example _rescued_data) so ingestion continues without failing on new columns.
  2. BUse cloudFiles.schemaEvolutionMode='addNewColumns' and disable checkpoints. This prevents schema-related failures and guarantees that new columns are written immediately to the target table.
  3. CProvide an explicit schema and set cloudFiles.schemaEvolutionMode='addNewColumns'. Auto Loader will automatically evolve the schema and never fail when new columns arrive.
  4. DRely on badRecordsPath to capture all unexpected columns and type mismatches; the rescued data column is only for malformed JSON records.
Show answer & explanation

Correct answer: A

WHY A: In rescue mode, Auto Loader does not fail the stream due to new columns and does not ignore unexpected fields; instead, it records unexpected columns/type/case mismatches in the rescued data column (default _rescued_data), allowing the pipeline to continue and letting you quarantine/inspect rescued content later. cloudFiles.schemaLocation and a stable checkpoint are required for reliable incremental processing and schema tracking. WHY NOT B: Removing checkpoints breaks exactly-once guarantees and can cause reprocessing; addNewColumns can still fail on new columns (it updates schema and the stream typically needs restart). WHY NOT C: When you provide a schema, addNewColumns is not allowed in the same way (and defaults differ); you typically need schema hints or other handling—this option is not the “never fail, keep running” setup. WHY NOT D: badRecordsPath stores malformed/corrupt records, but type mismatches and unexpected columns are specifically handled by the rescued data column when configured; they are not necessarily treated as “bad records” when rescued data is enabled.

Take the full DE Professional practice test →