← Delta Optimization track

Databricks · Delta Optimization · Lesson 01

Traditional Writes &
the Small-File Problem.

A default Spark write makes roughly one file per writing task. Do that on every micro-batch and you get thousands of tiny files — the quiet tax that makes every future query slower. This is the problem the whole track exists to fix.

Foundation Lesson 01 ~1 file per task Default shuffle 200 partitions Verified Jun 2026 docs

What it is

When Spark writes a DataFrame to a Delta table, it does not write one tidy file. The data is split into partitions; each parallel task writes its own file. So if 200 tasks are writing, you get roughly 200 files — even for a tiny result. Repeat that on every append, and you accumulate thousands of tiny files: the small-file problem.

Analogy 200 cashiers each bag one item into its own bag. You leave with 200 bags instead of a few well-packed ones — carrying them later (reading them) is slow and clumsy.

Use case A streaming job appends a micro-batch every 10 seconds. After a day that's ~8,600 commits, each writing several files. A dashboard query over that table now opens tens of thousands of files to answer one question.

Why it matters — the cost of a tiny file

A query's cost is not just how many bytes — it's also how many files. Every file carries fixed overhead the engine pays no matter how small it is:

The one rule to remember Do not coalesce(n) / repartition(n) right before a write when optimized writes is enabled — it fights the engine's own file sizing. Let the platform size files for you.

See it work — write → files

Pick how many writing tasks (shuffle partitions) the write fans out to, then choose a manual control. Watch the file grid, the file count, the approximate average file size, and a qualitative read-cost indicator.

Write → files simulator

A single write of a ~1 GB result. More tasks = more, smaller files unless the engine sizes them.

48
Files on disk
small file (overhead-heavy) right-sized file
—
files written
—
approx avg file size
—
relative read cost

Simplified illustration. Real file counts depend on AQE coalescing, data size, skew, and the target file size; sizes shown are approximate for a ~1 GB write.

How it works — deep dive

1 · One file per writing task

A write is a distributed job. Each partition is processed by one task, and each task writes its own file — so output files ≈ partitions at write time. More parallelism = a faster write but more, smaller files; less parallelism = fewer files but a slower write. You want both right-sized files and parallelism — exactly what optimized writes gives you for free later.

PySpark# The number of DataFrame partitions decides the number of output files
df = spark.range(0, 1_000_000)
print(df.rdd.getNumPartitions())   # e.g. 8 -> a plain write makes ~8 files

# Plain write: ~one Parquet file per partition. Delta is the default format.
df.write.mode("overwrite").saveAsTable("main.delta_opt_demo.events")

2 · spark.sql.shuffle.partitions & micro-batch appends multiply files

Any shuffle (join, groupBy, window) resets the partition count to spark.sql.shuffle.partitions (historically 200). A shuffle right before a write → ~200 files per write. Streaming/frequent batch jobs append on every micro-batch, so 200 files × thousands of commits = a tiny-file avalanche — and nothing in a plain write merges across commits.

PySpark# A shuffle repartitions the data to spark.sql.shuffle.partitions
spark.conf.get("spark.sql.shuffle.partitions")   # historically "200"

agg = df.groupBy((df.id % 5).alias("bucket")).count()  # forces a shuffle
# Without AQE/optimized writes this write would emit ~200 files for a tiny result.
agg.write.mode("overwrite").saveAsTable("main.delta_opt_demo.events_agg")

3 · The old manual controls: coalesce(n) vs repartition(n)

coalesce(n) — no shuffle. Merges neighboring partitions down to n. Cheap, but makes uneven files and can cut the upstream stage's parallelism. repartition(n) — full shuffle. Evenly redistributes into n partitions (clean sizes) but pays a full network shuffle. Both are brittle because you must guess n, and it goes stale as data grows.

PySpark# Naive (brittle) — guess a file count and force it before writing:
df.coalesce(1).write.saveAsTable("main.delta_opt_demo.events")      # one big file, kills parallelism
df.repartition(8).write.saveAsTable("main.delta_opt_demo.events")   # full shuffle, must guess 8

# Right (Databricks): DON'T coalesce/repartition before a write when
# optimized writes is on — let the platform size files (Lesson 05).
spark.conf.set("spark.databricks.delta.optimizeWrite.enabled", True)
df.write.mode("overwrite").saveAsTable("main.delta_opt_demo.events")

4 · AQE coalescing — a partial mitigation

Adaptive Query Execution (on by default) inspects the actual shuffle output sizes at runtime and coalesces many small post-shuffle partitions into fewer, larger ones — so a job that would have made 200 tiny partitions may collapse to ~12, with no tuning. But AQE tunes a single query; it never merges files across separate commits and isn't a layout solution. Append-heavy tables still accumulate small files.

PySpark# AQE is on by default; these are the relevant knobs (read, don't blindly change):
spark.conf.get("spark.sql.adaptive.enabled")                      # "true"
spark.conf.get("spark.sql.adaptive.coalescePartitions.enabled")   # "true"

5 · What a Delta write actually does (Parquet + _delta_log)

A Delta table = Parquet data files + a transaction log (_delta_log). A write (a) writes new Parquet files, then (b) commits one atomic JSON log entry listing the added files with their size + min/max/null/count stats. Readers trust the log, not the directory listing — that's what makes writes ACID. But a plain write does no file sizing; sizing only happens via optimized writes, auto compaction, or OPTIMIZE.

SQL-- The transaction log is the source of truth. Inspect what a write produced:
DESCRIBE DETAIL main.delta_opt_demo.events;   -- numFiles, sizeInBytes, format = delta
DESCRIBE HISTORY main.delta_opt_demo.events;  -- one row per commit: WRITE / MERGE / OPTIMIZE

6 · spark.sql.files.maxRecordsPerFile — capping rows per file

Caps how many rows go into one output file; when a task exceeds the cap it rolls to a new file. 0 or negative = no limit (the default). It's a guardrail against one task writing a single enormous file — but it bounds rows, not a true byte target, and set too low it creates small files.

PySpark# Cap rows per file (a guardrail against one giant file). 0/neg = no limit.
spark.conf.set("spark.sql.files.maxRecordsPerFile", 5_000_000)

# Or as a per-write option (does not change the session-wide config):
df.write.option("maxRecordsPerFile", 5_000_000) \
  .mode("overwrite").saveAsTable("main.delta_opt_demo.events")

The lifecycle of a Delta write

Click each step to expand. This is exactly what happens between df.write and a committed, queryable table.

Delta write, step by step

Job → tasks (one file each) → Parquet in cloud storage → atomic commit to _delta_log.

01 Job is planned
Spark turns df.write into a distributed job. The DataFrame's partition count (set by the input split or the last shuffle, e.g. spark.sql.shuffle.partitions) decides how many writing tasks there will be — and therefore the rough file count.
02 Tasks write — one file each
Each task runs on an executor and writes its own Parquet file. Tasks can't safely append to a shared file, so parallelism maps directly to file count. This is the root of the small-file problem: high parallelism on a small result → many tiny files.
03 Parquet files land in cloud storage
The new Parquet files are written to the table's storage location (a UC Volume / cloud path). At this moment they are not yet part of the table — readers can't see them. No file sizing has happened: the files are exactly what the tasks produced.
04 Atomic commit to _delta_log
Delta writes a single JSON commit to _delta_log that lists the added files plus per-file size and min/max/null/count statistics. The commit is atomic: until it lands, the write is invisible; once it lands, the whole batch appears at once. This is Delta's ACID guarantee.
05 File sizing? Only if you asked for it
A plain write stops here — whatever files the tasks made are now the table's layout. To get fewer, right-sized files you need optimized writes (size at write time), auto compaction (merge right after the commit), or OPTIMIZE (compact later). Those are Lessons 04–06.

Simplified illustration of the Delta write path; real commits also handle conflict detection, checkpoints, and protocol metadata.

What fixes this later

This lesson defines the problem; the rest of the track is the cure:

Comparison — the file-count controls

ControlShuffle?What it doesWhy it's brittle / its roleFor new code
coalesce(n)NoMerge partitions to nGuess n; uneven files; cuts upstream parallelismAvoid before writes
repartition(n)Yes (full)Even redistribution into nGuess n; full-shuffle cost; goes staleAvoid before writes
shuffle.partitionssets widthDefault post-shuffle count (~200)Global knob; big default → many tiny filesLeave to AQE
AQE coalesceruntimeCollapse small post-shuffle partitionsPer-query only; can't merge across commitsKeep on (default)
maxRecordsPerFileNoCap rows per fileRow cap, not a byte target; can create small filesGuardrail only
Optimized writesYes (managed)Size files at write timeThe recommended fix (L05)Prefer
OPTIMIZE / auto compactionn/aCompact files after writeCleans up what exists (L04/L06)Prefer

Uses, edge cases & limitations

Uses (when the manual controls still make sense)

Edge cases an interviewer probes

Limitations

Common gotchas