Write and review PySpark against Iceberg tables — schema discipline, native functions over UDFs, correct write mode, and table maintenance.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add elitongadotti/cockpit --skill pycegerb-yoda --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Pycegerb Yoda?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/elitongadotti-pycegerb-yoda)More formats (shields.io, HTML) on the badges page.
---
name: pycegerb-yoda
description: Write and review PySpark against Iceberg tables — schema discipline, native functions over UDFs, correct write mode, and table maintenance.
when_to_use: writing or reviewing a PySpark job, a Spark SQL query on Iceberg, an OOM or skew problem, a slow read, table maintenance, partitioning or schema evolution and prunning, or a file-size problem
---
# PySpark on Iceberg
Every claim here is traceable to the Apache Spark or Apache Iceberg docs — links
per section. Version-sensitive defaults are marked; verify against the version
you run before relying on one.
## Schema and types
- **Declare the schema; don't infer it.** `inferSchema` costs an extra pass over
the data and pins correctness to whatever the sample happened to contain. Pass
an explicit `StructType`.
- **Never `select("*")` into a write.** Column order and presence become part of
your contract the moment something downstream reads it.
- **`toPandas()` and `createDataFrame(pandas_df)` move data through the driver.**
The docs are explicit that this should be "done on a small subset of the data",
and that not all Spark/Arrow types are supported — an unsupported column type
raises. ([Arrow in PySpark](https://spark.apache.org/docs/latest/api/python/tutorial/sql/arrow_pandas.html))
## Transformations that don't bloat the plan
- **Don't chain `withColumn()` in a loop.** The PySpark docstring is explicit:
it "introduces a projection internally", so calling it repeatedly "can generate
big plans which can cause performance issues and even `StackOverflowException`".
Use one `select()` with all the columns, or `withColumns({...})` (Spark 3.3.0+)
to add many at once.
- **`try_cast` instead of `cast` on untrusted input.** It is "identical to `CAST`,
except that it returns `NULL` result instead of throwing an exception on runtime
error" — Spark's own `CAST_INVALID_INPUT` and `CAST_OVERFLOW` errors tell you to
reach for it. In ANSI mode a bad row otherwise kills the job.
([ANSI compliance](https://spark.apache.org/docs/latest/sql-ref-ansi-compliance.html))
- **`eqNullSafe` when joining on nullable keys.** `=` returns NULL when either
side is NULL, so those rows silently drop; `eqNullSafe` is an "equality test
that is safe for null values" (since 2.3.0).
- **Qualify columns after a join.** Once two frames share a name, an unqualified
reference is ambiguous — keep the frame alias on both sides.
## Native functions over Python UDFs
Reach for `pyspark.sql.functions` first. A Python UDF is a black box to the
Catalyst optimizer and pays per-row serialization between the JVM and the Python
worker.
When you genuinely need Python, prefer a **pandas UDF** (`@pandas_udf`) over a
plain `udf` — it uses Arrow to transfer data in columnar batches instead of
row-by-row.
Documented caveats worth knowing before you ship one:
- **Arrow is on by default** via `spark.sql.execution.arrow.pyspark.enabled`, and
silently falls back to the non-Arrow path on error unless you disable
`spark.sql.execution.arrow.pyspark.fallback.enabled`. A job that "works" may be
on the slow path.
- **Batch size is capped by `spark.sql.execution.arrow.maxRecordsPerBatch`**
(default 10,000 rows). The docs note that with many columns the value "should
be adjusted accordingly", since batches raise JVM memory use.
- **`applyInPandas` / `cogroup` load an entire group into memory** — the docs
warn this "can lead to out of memory exceptions, especially if the group sizes
are skewed", and that `maxRecordsPerBatch` does *not* apply. Group size is your
responsibility.
- `MapType` and `ArrayType` of nested `StructType` have restricted support.
([Arrow / pandas UDFs](https://spark.apache.org/docs/latest/api/python/tutorial/sql/arrow_pandas.html))
## Let AQE do the tuning
Adaptive Query Execution is enabled by default since Spark 3.2.0
(`spark.sql.adaptive.enabled`). Before hand-tuning `spark.sql.shuffle.partitions`,
check that you aren't fighting it:
- **Coalescing small partitions** — `coalescePartitions.enabled` targets
`advisoryPartitionSizeInBytes` (default 64MB). On a busy cluster the docs
recommend setting `coalescePartitions.parallelismFirst=false`, since the
default `true` ignores the target size and maximizes parallelism instead.
- **Skewed joins** — `skewJoin.enabled` splits skewed sort-merge-join tasks. A
partition counts as skewed when it exceeds *both* `skewedPartitionFactor` ×
median *and* `skewedPartitionThresholdInBytes`. The docs advise setting that
threshold larger than `advisoryPartitionSizeInBytes`.
([Performance tuning](https://spark.apache.org/docs/latest/sql-performance-tuning.html))
## Writing to Iceberg
Pick the write op deliberately — this is where most data bugs get introduced.
| Intent | Use | Note |
|---|---|---|
| Append rows | `INSERT INTO` / `df.writeTo(t).append()` | |
| Row-level upsert | `MERGE INTO` | **Recommended over `INSERT OVERWRITE`** |
| Replace touched partitions | `df.writeTo(t).overwritePartitions()` | dynamic overwrite |
| Replace by explicit filter | `df.writeTo(t).overwrite(<filter>)` | |
| CTAS / RTAS | `.create()` / `.replace()` / `.createOrReplace()` | |
**Use `MERGE INTO` rather than `INSERT OVERWRITE`.** The docs give two reasons:
Iceberg can replace only the affected data files, and "the data overwritten by a
dynamic overwrite may change if the table's partitioning changes."
**If you do overwrite, know which mode you're in.** Spark's default is *static*,
but **dynamic is what the Iceberg docs recommend** — set
`spark.sql.sources.partitionOverwriteMode=dynamic`. This matters more than it
sounds: in static mode an `INSERT OVERWRITE` with no `PARTITION` clause **drops
every existing row in the table**. Static mode also can't target hidden
partitions, because the `PARTITION` clause only references table columns.
Other documented behavior:
- `MERGE INTO`, `UPDATE`, and row-level `DELETE FROM` require the **Iceberg Spark
SQL extensions**. Plain `INSERT INTO` / `INSERT OVERWRITE` need
`spark.sql.storeAssignmentPolicy=ANSI` (the default since Spark 3.0).
- In a `MERGE INTO`, only one source row may match a given target row, "or else
an error will be thrown." Deduplicate the source first.
- `DELETE FROM` matching whole partitions is a **metadata-only** delete; matching
individual rows rewrites just the affected files.
- Prefer the **v2 writer** (`df.writeTo(...)`). The v1 `format("iceberg")` path
carries an explicit danger note: it "loads an isolated table reference that
will not automatically refresh tables used by queries." Use `saveAsTable` /
`insertInto` if you must stay on v1.
- Schema merge on write is opt-in on **both** sides — table property
`write.spark.accept-any-schema=true` *and* writer `.option("mergeSchema","true")`.
([Iceberg Spark writes](https://iceberg.apache.org/docs/latest/spark-writes/))
## Partitioning and evolution
- **Don't add a derived partition column by hand.** Iceberg uses *hidden
partitioning*: it produces partition values from a source column via a
transform (`days(ts)`, `bucket(8, id)`, …) and tracks the relationship, so
queries filter on the real column and still prune files. The docs call out the
Hive-style alternative as producing "silently incorrect results" when a
consumer formats the partition column wrong.
- **Schema evolution is a metadata change** — adding, dropping, renaming or
reordering a column rewrites no data files, and the docs guarantee the changes
are "independent and free of side-effects".
- **Partition evolution is also metadata-only** and "does not eagerly rewrite
files." Old data keeps its old spec; Iceberg plans each layout separately
(split planning). So a partitioning mistake is fixable in place — no migration.
([Evolution](https://iceberg.apache.org/docs/latest/evolution/) ·
[Partitioning](https://iceberg.apache.org/docs/latest/partitioning/))
## Write distribution and file sizes
Since Iceberg 1.2.0 the default `write.distribution-mode` is `hash`, which asks
Spark to shuffle rows by partition value so each task writes few files. The three
modes: `none` (no shuffle — you must sort manually), `hash` (default), `range`
(two-stage, more expensive, globally sorted; the default when the table has a
sort order). Note Spark ignored distribution mode in CTAS/RTAS before 3.5.0.
On file sizes: Spark cannot write a file larger than a task, and a file can't
span a partition boundary — so `write.target-file-size-bytes` only takes effect
if tasks are big enough. AQE's `advisoryPartitionSizeInBytes` is measured on
row-based shuffle data, while the output is columnar and better compressed, so it
needs to be set **larger** than the target file size.
([Iceberg Spark writes](https://iceberg.apache.org/docs/latest/spark-writes/))
## Maintenance
Every write creates a snapshot; snapshots and small files accumulate until
something reclaims them. Run these as Spark SQL procedures:
```sql
-- reclaim expired snapshots and their exclusive files
CALL catalog.system.expire_snapshots(table => 'db.t', older_than => TIMESTAMP '2024-01-01 00:00:00.000', retain_last => 100);
-- compact small files (strategy: binpack default, or sort / zorder)
CALL catalog.system.rewrite_data_files(table => 'db.t', strategy => 'sort', sort_order => 'id DESC NULLS LAST');
-- regroup manifests when write order stops matching read filters
CALL catalog.system.rewrite_manifests('db.t');
-- ALWAYS dry_run first
CALL catalog.system.remove_orphan_files(table => 'db.t', dry_run => true);
```
Documented safety rules — the destructive ones:
- **`remove_orphan_files` can cause data loss.** Its retention interval must not
be shorter than the longest in-flight write, or in-progress files get treated
as orphans; the default is 3 days. Iceberg compares **string paths**, so a
changed filesystem authority makes nothing match — the docs state plainly that
this "will lead to data loss when RemoveOrphanFiles is run."
- `expire_snapshots` defaults to 5 days ago and retains the last 1 snapshot; it
never removes files a live snapshot still needs. Snapshots held by branches or
tags survive, and `main` never expires. Expiring destroys time travel to those
snapshots.
- Set `stream_results => true` on both procedures for large tables — recommended
in the docs to avoid driver OOM.
- Old **metadata** files are a separate problem: set
`write.metadata.delete-after-commit.enabled=true` (default `false`) to prune
tracked ones as they're superseded. It won't touch already-untracked files —
only orphan-file removal reaches those.
([Maintenance](https://iceberg.apache.org/docs/latest/maintenance/) ·
[Spark procedures](https://iceberg.apache.org/docs/latest/spark-procedures/))
## Source
Written from the Apache Spark and Apache Iceberg documentation (both Apache-2.0),
which every claim above is traceable to.
Prompted by
[skill-pyspark-expert](https://github.com/leandroasaservice/skill-pyspark-expert)
by leandroasaservice. That skill is GPL-3.0 and this repo is MIT, so no text was
copied from it. Its subject matter was surveyed to decide what belonged here —
it focuses on code style and testing rather than performance — and the few
overlapping topics (`withColumn` chains, `try_cast`, `eqNullSafe`) were then
written independently from the Spark docs and source docstrings.
Verifying rather than trusting caught one error worth noting: `eqNullSafe` is
sometimes described as a Spark 3.5+ feature, but its docstring reads
`.. versionadded:: 2.3.0`. Check version claims against the docs for the release
you actually run.
## Pairs with
- `sql-review` — the general query review pass; this skill is the engine-specific layer.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!