This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new b1206c60a feat(table): support dynamic partition overwrite (#2381)
b1206c60a is described below
commit b1206c60aeabb6a9a31f72daccf98305dcf5d759
Author: Minh Vu <[email protected]>
AuthorDate: Mon Nov 9 19:23:52 2026 +0100
feat(table): support dynamic partition overwrite (#2381)
* feat(table): support dynamic partition overwrite
Signed-off-by: Minh Vu <[email protected]>
* fix(table): honor cancellation before dynamic overwrite staging
Check cancellation before writing files and registering commit requirements.
Signed-off-by: Minh Vu <[email protected]>
* fix(table): preserve dynamic overwrite semantics for empty and
unpartitioned partitions
* test(table): cover unpartitioned dynamic overwrite and new partitions
* perf(table): reuse validated paths during partition overwrite
* test(table): cover v1 dynamic partition overwrite
Signed-off-by: Minh Vu <[email protected]>
* test(table): verify dynamic overwrite ref pin with commit retries
* fix(table): validate touched partitions on dynamic overwrite
* test(table): cover v3 and mixed-spec dynamic overwrite
* test(table): verify dynamic overwrite retry and branch fencing
* docs(table): clarify partition predicate scope
* test(table): fix partition iterator and assert shape rejection
* test(table): preserve only untouched partition deletes
* style(table): satisfy nlreturn in overwrite helpers
* style(table): satisfy nlreturn in partition test
* fix(table): inherit v3 row IDs during dynamic overwrite
* fix(table): resolve dynamic overwrite lint and test assertion
* test(table): cover null and date dynamic partition overwrites
* test(table): cover dynamic overwrite delete conflicts and DV cleanup
* style(table): satisfy nlreturn in dynamic overwrite tests
* style(table): gofmt dynamic overwrite integration tests
* fix(table): pass context to dynamic overwrite manifest APIs
* test(table): keep main intact on branch dynamic overwrite
* fix(table): preserve old specs in dynamic overwrite
* fix(table): remove unused overwrite locals
* fix(table): preserve deletes for surviving partition-spec data
* fix(table): fence old-spec writes in unpartitioned overwrite retries
* style(table): format dynamic overwrite regression tests
* test(table): filter delete files from dynamic overwrite data assertion
* docs(table): clarify dynamic overwrite with evolved partition specs
* test(table): cover staged dynamic overwrite and Arrow table API
---------
Signed-off-by: Minh Vu <[email protected]>
---
table/dynamic_partition_overwrite.go | 444 ++++++++++++++++++
...dynamic_partition_overwrite_integration_test.go | 436 ++++++++++++++++++
table/dynamic_partition_overwrite_test.go | 511 +++++++++++++++++++++
table/internal/partition_predicate.go | 10 +-
website/src/api.md | 35 ++
5 files changed, 1431 insertions(+), 5 deletions(-)
diff --git a/table/dynamic_partition_overwrite.go
b/table/dynamic_partition_overwrite.go
new file mode 100644
index 000000000..942bdd300
--- /dev/null
+++ b/table/dynamic_partition_overwrite.go
@@ -0,0 +1,444 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package table
+
+import (
+ "context"
+ "fmt"
+ "maps"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/iceberg-go"
+ "github.com/google/uuid"
+)
+
+// dynamicPartitionKeys normalizes all partition values before comparing them.
+// A manifest decoder may represent the same logical day as an int32 while the
+// writer uses iceberg.Date. The shared canonical encoder handles both forms,
+// decimals, bytes, UUIDs, and nulls without lossy string formatting.
+func dynamicPartitionKeys(files []iceberg.DataFile) (map[string]struct{},
error) {
+ keys := make(map[string]struct{}, len(files))
+ for _, file := range files {
+ key, err := canonicalPartitionKey(file.SpecID(),
dataFilePartition(file))
+ if err != nil {
+ return nil, fmt.Errorf("dynamic partition overwrite
file %q: %w", file.FilePath(), err)
+ }
+ keys[key] = struct{}{}
+ }
+
+ return keys, nil
+}
+
+func (t *Transaction) dynamicOverwriteSpec() (*iceberg.PartitionSpec, error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, err
+ }
+
+ if meta.formatVersion > 3 {
+ return nil, fmt.Errorf("%w: dynamic partition overwrite does
not support format v%d", ErrInvalidOperation, meta.formatVersion)
+ }
+
+ spec, err := meta.CurrentSpec()
+ if err != nil {
+ return nil, err
+ }
+ if spec == nil {
+ return nil, fmt.Errorf("%w: dynamic partition overwrite
requires a current partition spec", ErrInvalidOperation)
+ }
+ if err := checkNoUnknownTransform(spec); err != nil {
+ return nil, err
+ }
+
+ return spec, nil
+}
+
+// validateDynamicOverwriteConflicts uses exact stored partition tuples so
+// transformed and null partitions cannot become false-negative row predicates.
+// It rejects concurrent additions and removals in touched partitions while
+// allowing unrelated partition appends during refresh-and-replay.
+func validateDynamicOverwriteConflicts(cc *conflictContext, specID int,
unpartitioned bool, touched map[string]struct{}) error {
+ if cc == nil || len(cc.concurrent) == 0 {
+ return nil
+ }
+ for _, snapshot := range cc.concurrent {
+ manifests, err := cc.manifestsFor(snapshot)
+ if err != nil {
+ return fmt.Errorf("dynamic partition overwrite:
concurrent manifests for snapshot %d: %w", snapshot.SnapshotID, err)
+ }
+ for _, manifest := range manifests {
+ for entry, err := range manifest.Entries(cc.ctx,
cc.manifestReadIO(), false) {
+ if err != nil {
+ return fmt.Errorf("dynamic partition
overwrite: read concurrent manifest %s: %w", manifest.FilePath(), err)
+ }
+ if entry.SnapshotID() != snapshot.SnapshotID ||
entry.Status() == iceberg.EntryStatusEXISTING {
+ continue
+ }
+ file := entry.DataFile()
+ conflict := ErrConflictingDataFiles
+ if file.ContentType() !=
iceberg.EntryContentData {
+ conflict = ErrConflictingDeleteFiles
+ }
+ // No partition scope exists for an
unpartitioned overwrite.
+ // Every concurrent change matters, including
older spec IDs.
+ if unpartitioned {
+ return fmt.Errorf("%w: concurrent
snapshot %d changed file %q during an unpartitioned overwrite",
+ conflict, snapshot.SnapshotID,
file.FilePath())
+ }
+ if int(file.SpecID()) != specID {
+ continue
+ }
+ key, err :=
canonicalPartitionKey(file.SpecID(), dataFilePartition(file))
+ if err != nil {
+ return fmt.Errorf("dynamic partition
overwrite: concurrent file %q: %w", file.FilePath(), err)
+ }
+ if _, ok := touched[key]; ok {
+ return fmt.Errorf("%w: concurrent
snapshot %d changed file %q in an overwritten partition",
+ conflict, snapshot.SnapshotID,
file.FilePath())
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// DynamicPartitionOverwriteDataFiles atomically replaces all live data in
+// the partitions represented by filesToAdd. Empty input is a no-op; an
+// unpartitioned table has one partition. New partitions still use OpOverwrite.
+//
+// Added files must use the table's current partition spec; existing live files
+// under older specs are preserved unless the current spec is unpartitioned,
+// in which case all existing files are replaced. Supports v1-v3, including v3
row lineage
+// and associated deletion vectors. On v3, a new data file may omit
first_row_id
+// and inherit it from its manifest.
+// Concurrent writes to touched partitions fail while independent appends to
+// other partitions can be retried unless delete-file removal prevents replay.
+// A missing target branch is pinned together with main to guard its origin.
+// Default partition and sort order changes also fail.
+//
+// All input files must already exist. Uncommitted data files remain the
+// caller's responsibility after a staging or commit failure. By default this
+// sets the table's name mapping; WithoutAutoNameMapping disables that step.
+// WithoutDuplicateCheck is ignored to avoid adding an already live file twice.
+func (t *Transaction) DynamicPartitionOverwriteDataFiles(
+ ctx context.Context, filesToAdd []iceberg.DataFile, snapshotProps
iceberg.Properties, opts ...WriteOption,
+) error {
+ spec, err := t.dynamicOverwriteSpec()
+ if err != nil {
+ return err
+ }
+ if len(filesToAdd) == 0 {
+ return nil
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ var cfg dataFileCfg
+ for _, option := range opts {
+ option(&cfg)
+ }
+ if cfg.rewriteSemantics {
+ return fmt.Errorf("%w: dynamic partition overwrite cannot use
data-preserving rewrite semantics", ErrInvalidOperation)
+ }
+
+ // Dynamic overwrite adds new rows, so v3 first_row_id can be inherited
+ // from the manifest. Java ReplacePartitions uses the same inheritance.
+ addedPaths, err := t.validateDataFilesToAdd(filesToAdd,
"DynamicPartitionOverwriteDataFiles", false)
+ if err != nil {
+ return err
+ }
+ for _, df := range filesToAdd {
+ if int(df.SpecID()) != spec.ID() {
+ return fmt.Errorf("%w: dynamic partition overwrite file
%q uses spec %d, current spec is %d",
+ ErrInvalidOperation, df.FilePath(),
df.SpecID(), spec.ID())
+ }
+ }
+
+ fs, err := t.tbl.fsF(ctx)
+ if err != nil {
+ return err
+ }
+ wfs, err := requireWriteFileIO(fs)
+ if err != nil {
+ return err
+ }
+
+ keys, err := dynamicPartitionKeys(filesToAdd)
+ if err != nil {
+ return err
+ }
+ var toDelete, deleteFilesToRemove []iceberg.DataFile
+ unpartitioned := spec.IsUnpartitioned()
+ survivingDataPaths := make(map[string]struct{})
+ var hasSurvivingOldSpecData bool
+ validationPlan := newPartitionValidationPlan(spec)
+ if snapshot := t.planningSnapshot(t.meta); snapshot != nil {
+ for df, err := range snapshot.dataFiles(ctx, fs, nil) {
+ if err != nil {
+ return fmt.Errorf("dynamic partition overwrite:
read data files: %w", err)
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if _, exists := addedPaths[df.FilePath()]; exists {
+ return fmt.Errorf("%w: added file %q already
exists in the target snapshot", ErrInvalidOperation, df.FilePath())
+ }
+ if df.ContentType() != iceberg.EntryContentData {
+ continue
+ }
+ if int(df.SpecID()) != spec.ID() {
+ if !unpartitioned {
+ survivingDataPaths[df.FilePath()] =
struct{}{}
+ hasSurvivingOldSpecData = true
+
+ continue
+ }
+ } else if err := validationPlan.validate(df); err !=
nil {
+ return fmt.Errorf("%w: invalid live partition
in file %q: %w",
+ ErrInvalidOperation, df.FilePath(), err)
+ }
+
+ // A current unpartitioned spec represents the whole
table, even
+ // when existing files were written under older
partition specs.
+ if unpartitioned {
+ toDelete = append(toDelete, df)
+
+ continue
+ }
+
+ key, err := canonicalPartitionKey(df.SpecID(),
dataFilePartition(df))
+ if err != nil {
+ return fmt.Errorf("dynamic partition overwrite
live file %q: %w", df.FilePath(), err)
+ }
+ if _, touched := keys[key]; touched {
+ toDelete = append(toDelete, df)
+ } else {
+ survivingDataPaths[df.FilePath()] = struct{}{}
+ }
+ }
+
+ // A replaced partition has no surviving current-spec data that
+ // its old deletes should affect. Preserve deletes that can
still
+ // target surviving files under an older spec. Deletion vectors
are
+ // removed separately with their referenced data files.
+ for entry, err := range snapshot.entries(ctx, fs,
iceberg.ManifestContentDeletes, true) {
+ if err != nil {
+ return fmt.Errorf("dynamic partition overwrite:
read delete files: %w", err)
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ df := entry.DataFile()
+ if _, exists := addedPaths[df.FilePath()]; exists {
+ return fmt.Errorf("%w: added file %q already
exists as a delete file", ErrInvalidOperation, df.FilePath())
+ }
+ if IsDeletionVector(df) {
+ continue
+ }
+ if unpartitioned {
+ deleteFilesToRemove =
append(deleteFilesToRemove, df)
+
+ continue
+ }
+ if ref := df.ReferencedDataFile(); ref != nil && *ref
!= "" {
+ if _, survives := survivingDataPaths[*ref];
survives {
+ continue
+ }
+ } else if df.ContentType() ==
iceberg.EntryContentPosDeletes && hasSurvivingOldSpecData {
+ // V2 manifests do not carry
referenced_data_file. A position
+ // delete may still target an old-spec file, so
do not discard
+ // it while any such data file survives.
+ continue
+ }
+ if int(df.SpecID()) != spec.ID() {
+ continue
+ }
+ key, err := canonicalPartitionKey(df.SpecID(),
dataFilePartition(df))
+ if err != nil {
+ return fmt.Errorf("dynamic partition overwrite
live delete file %q: %w", df.FilePath(), err)
+ }
+ if _, touched := keys[key]; touched {
+ deleteFilesToRemove =
append(deleteFilesToRemove, df)
+ }
+ }
+ }
+
+ var dvs liveDeletionVectors
+ if t.meta.formatVersion >= 3 && len(toDelete) != 0 {
+ if dvs, err = t.readLiveDeletionVectors(ctx, fs); err != nil {
+ return err
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ if err := t.AssertDefaultShape(); err != nil {
+ return err
+ }
+ branch := t.branch
+ if branch == "" {
+ branch = MainBranch
+ }
+ if t.baseRefSnapshotID(branch) == nil {
+ if err := t.AssertRefSnapshotID(branch); err != nil {
+ return err
+ }
+ if branch != MainBranch {
+ if err := t.AssertRefSnapshotID(MainBranch); err != nil
{
+ return err
+ }
+ }
+ }
+
+ if !cfg.skipAutoNameMapping {
+ if err := t.ensureNameMapping(); err != nil {
+ return err
+ }
+ }
+
+ props := make(iceberg.Properties, len(snapshotProps)+1)
+ maps.Copy(props, snapshotProps)
+ props["replace-partitions"] = "true"
+
+ // Use one producer for additions and removals. In contrast,
+ // ReplaceDataFiles would scan manifests again and produce an append
+ // operation when there are no existing files in the touched partitions.
+ producer := newOverwriteFilesProducer(OpOverwrite, t, wfs, nil, props)
+ producer.setSkipDefaultValidator(true)
+ for _, file := range toDelete {
+ dvs.removeDataFile(producer, file)
+ }
+ for _, file := range deleteFilesToRemove {
+ producer.removeDeleteFile(file)
+ }
+ for _, file := range filesToAdd {
+ producer.appendDataFile(file)
+ }
+ updates, reqs, err := producer.commit(ctx)
+ if err != nil {
+ return err
+ }
+
+ // Publish the staged snapshot and validator together so concurrent
+ // Commit cannot see one without the other.
+ t.mx.Lock()
+ defer t.mx.Unlock()
+ if err := t.applyLocked(updates, reqs); err != nil {
+ return err
+ }
+ t.validators = append(t.validators, func(cc *conflictContext) error {
+ return validateDynamicOverwriteConflicts(cc, spec.ID(),
unpartitioned, keys)
+ })
+
+ return nil
+}
+
+// DynamicPartitionOverwrite writes Arrow records to data files and replaces
+// only the partitions represented by the written records. Empty input is
+// a no-op. Supports v1-v3 and preserves live files under older partition
+// specs. Concurrent touched-partition changes fail; removal of delete files or
+// DVs prevents replay. Written files remain the caller's responsibility after
+// failure. Pass WithoutAutoNameMapping to avoid changing that property.
+func (t *Transaction) DynamicPartitionOverwrite(
+ ctx context.Context, rdr array.RecordReader, snapshotProps
iceberg.Properties, opts ...WriteOption,
+) error {
+ if rdr == nil {
+ return fmt.Errorf("%w: nil record reader for dynamic partition
overwrite", ErrInvalidOperation)
+ }
+ if _, err := t.dynamicOverwriteSpec(); err != nil {
+ return err
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ meta, err := t.txnMeta()
+ if err != nil {
+ return err
+ }
+ fs, err := t.tbl.fsF(ctx)
+ if err != nil {
+ return err
+ }
+ wfs, err := requireWriteFileIO(fs)
+ if err != nil {
+ return err
+ }
+
+ writeUUID := uuid.New()
+ itr := recordsToDataFiles(ctx, t.tbl.Location(), meta,
recordWritingArgs{
+ sc: rdr.Schema(),
+ itr: array.IterFromReader(rdr),
+ fs: wfs,
+ writeUUID: &writeUUID,
+ })
+
+ var written []iceberg.DataFile
+ for df, err := range itr {
+ if err != nil {
+ return err
+ }
+ written = append(written, df)
+ }
+
+ return t.DynamicPartitionOverwriteDataFiles(ctx, written,
snapshotProps, opts...)
+}
+
+// DynamicPartitionOverwriteTable is the Arrow-table variant of
+// DynamicPartitionOverwrite. It shares the v3, older-spec file handling,
+// concurrency and uncommitted-file behavior of the record-reader method.
+func (t *Transaction) DynamicPartitionOverwriteTable(
+ ctx context.Context, tbl arrow.Table, batchSize int64, snapshotProps
iceberg.Properties, opts ...WriteOption,
+) error {
+ if tbl == nil {
+ return fmt.Errorf("%w: nil Arrow table for dynamic partition
overwrite", ErrInvalidOperation)
+ }
+ rdr := array.NewTableReader(tbl, batchSize)
+ defer rdr.Release()
+
+ return t.DynamicPartitionOverwrite(ctx, rdr, snapshotProps, opts...)
+}
+
+// DynamicPartitionOverwrite commits a dynamic overwrite in one transaction.
+// It shares the older-spec, v3, concurrency and uncommitted-file rules above.
+func (t Table) DynamicPartitionOverwrite(
+ ctx context.Context, rdr array.RecordReader, snapshotProps
iceberg.Properties, opts ...WriteOption,
+) (*Table, error) {
+ txn := t.NewTransaction()
+ if err := txn.DynamicPartitionOverwrite(ctx, rdr, snapshotProps,
opts...); err != nil {
+ return nil, err
+ }
+
+ return txn.Commit(ctx)
+}
+
+// DynamicPartitionOverwriteTable is the Arrow-table convenience wrapper.
+// It shares the older-spec, v3, concurrency and uncommitted-file rules above.
+func (t Table) DynamicPartitionOverwriteTable(
+ ctx context.Context, tbl arrow.Table, batchSize int64, snapshotProps
iceberg.Properties, opts ...WriteOption,
+) (*Table, error) {
+ txn := t.NewTransaction()
+ if err := txn.DynamicPartitionOverwriteTable(ctx, tbl, batchSize,
snapshotProps, opts...); err != nil {
+ return nil, err
+ }
+
+ return txn.Commit(ctx)
+}
diff --git a/table/dynamic_partition_overwrite_integration_test.go
b/table/dynamic_partition_overwrite_integration_test.go
new file mode 100644
index 000000000..9fbc4f7bd
--- /dev/null
+++ b/table/dynamic_partition_overwrite_integration_test.go
@@ -0,0 +1,436 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table_test
+
+import (
+ "os"
+ "path/filepath"
+ "slices"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/table"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newDynamicOverwriteTable(t *testing.T) (*table.Table,
*concurrentTestCatalog) {
+ spec := iceberg.NewPartitionSpec(iceberg.PartitionField{SourceIDs:
[]int{1}, FieldID: 1000, Name: "id", Transform: iceberg.IdentityTransform{}})
+
+ return newDynamicOverwriteTableWithSpec(t, spec)
+}
+
+func newDynamicOverwriteTableWithSpec(t *testing.T, spec
iceberg.PartitionSpec) (*table.Table, *concurrentTestCatalog) {
+ t.Helper()
+
+ return newDynamicOverwriteTableWithFormat(t, spec, "2")
+}
+
+func newDynamicOverwriteTableWithFormat(t *testing.T, spec
iceberg.PartitionSpec, formatVersion string) (*table.Table,
*concurrentTestCatalog) {
+ t.Helper()
+ schema := iceberg.NewSchema(0, iceberg.NestedField{ID: 1, Name: "id",
Type: iceberg.PrimitiveTypes.Int64, Required: true})
+
+ return newDynamicOverwriteTableWithSchema(t, schema, spec,
formatVersion)
+}
+
+func newDynamicOverwriteTableWithSchema(t *testing.T, schema *iceberg.Schema,
spec iceberg.PartitionSpec, formatVersion string) (*table.Table,
*concurrentTestCatalog) {
+ t.Helper()
+ location := filepath.ToSlash(t.TempDir())
+ meta, err := table.NewMetadata(schema, &spec, table.UnsortedSortOrder,
location, iceberg.Properties{
+ table.PropertyFormatVersion: formatVersion,
+ table.CommitNumRetriesKey: "2",
+ table.CommitMinRetryWaitMsKey: "1",
+ table.CommitMaxRetryWaitMsKey: "2",
+ table.CommitTotalRetryTimeoutMsKey: "1000",
+ })
+ require.NoError(t, err)
+ metaLoc := location + "/metadata/v1.metadata.json"
+ cat := &concurrentTestCatalog{metadata: meta, location: metaLoc, fsF:
branchPlanningFS}
+
+ return table.New(branchPlanningIdent, meta, metaLoc, branchPlanningFS,
cat), cat
+}
+
+func TestDynamicPartitionOverwriteArrowAndConcurrentCommit(t *testing.T) {
+ base, cat := newDynamicOverwriteTable(t)
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+ txn := tbl.NewTransaction()
+ reader := idRecordReader(t, tbl, 1, 3)
+ require.NoError(t, txn.DynamicPartitionOverwrite(t.Context(), reader,
nil))
+ reader.Release()
+ updated, err := txn.Commit(t.Context())
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []int64{1, 2, 3}, idsOnRef(t, updated,
table.MainBranch))
+
+ stale := updated.NewTransaction()
+ reader = idRecordReader(t, updated, 1)
+ require.NoError(t, stale.DynamicPartitionOverwrite(t.Context(), reader,
nil))
+ reader.Release()
+ updated = appendRowsOnRef(t, updated, table.MainBranch, 1)
+ attemptsBefore := cat.attempts.Load()
+ _, err = stale.Commit(t.Context())
+ require.ErrorIs(t, err, table.ErrConflictingDataFiles)
+ assert.Equal(t, attemptsBefore+1, cat.attempts.Load(),
+ "touched partition conflict should fail before another catalog
submission")
+ assert.ElementsMatch(t, []int64{1, 1, 2, 3}, idsOnRef(t, updated,
table.MainBranch))
+}
+
+// An append staged in the same transaction must be visible to dynamic
+// overwrite planning: touched rows are replaced, and other rows survive.
+func TestDynamicPartitionOverwriteAfterAppendInSameTransaction(t *testing.T) {
+ base, _ := newDynamicOverwriteTable(t)
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+ txn := tbl.NewTransaction()
+
+ appended := idRecordReader(t, tbl, 1, 3)
+ require.NoError(t, txn.Append(t.Context(), appended, nil))
+ appended.Release()
+
+ replacement := idRecordReader(t, tbl, 1)
+ require.NoError(t, txn.DynamicPartitionOverwrite(t.Context(),
replacement, nil))
+ replacement.Release()
+
+ updated, err := txn.Commit(t.Context())
+ require.NoError(t, err)
+ require.ElementsMatch(t, []int64{1, 2, 3}, idsOnRef(t, updated,
table.MainBranch))
+}
+
+func TestDynamicPartitionOverwriteTable(t *testing.T) {
+ base, _ := newDynamicOverwriteTable(t)
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+ arrowSchema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+ data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema,
+ []string{`[{"id":1},{"id":3}]`})
+ require.NoError(t, err)
+ defer data.Release()
+
+ updated, err := tbl.DynamicPartitionOverwriteTable(t.Context(), data,
1, nil)
+ require.NoError(t, err)
+ require.ElementsMatch(t, []int64{1, 2, 3}, idsOnRef(t, updated,
table.MainBranch))
+}
+
+func TestDynamicPartitionOverwriteAllowsConcurrentOtherPartition(t *testing.T)
{
+ base, cat := newDynamicOverwriteTable(t)
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+ stale := tbl.NewTransaction()
+ reader := idRecordReader(t, tbl, 1, 3)
+ require.NoError(t, stale.DynamicPartitionOverwrite(t.Context(), reader,
nil))
+ reader.Release()
+
+ peer := appendRowsOnRef(t, tbl, table.MainBranch, 4)
+ attemptsBefore := cat.attempts.Load()
+ result, err := stale.Commit(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, attemptsBefore+2, cat.attempts.Load(),
"different-partition append should allow refresh and retry")
+ assert.ElementsMatch(t, []int64{1, 2, 3, 4}, idsOnRef(t, result,
table.MainBranch))
+ assert.ElementsMatch(t, []int64{1, 2, 4}, idsOnRef(t, peer,
table.MainBranch))
+}
+
+// Overwriting an existing branch must plan against that branch's files,
+// rather than replacing files that only main references.
+func TestDynamicPartitionOverwriteExistingBranchKeepsMainUnchanged(t
*testing.T) {
+ base, _ := newDynamicOverwriteTable(t)
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+ tbl = appendRowsOnRef(t, tbl, branchPlanningRef, 1, 3)
+ tbl = appendRowsOnRef(t, tbl, table.MainBranch, 4)
+
+ require.ElementsMatch(t, []int64{1, 1, 2, 3}, idsOnRef(t, tbl,
branchPlanningRef))
+ require.ElementsMatch(t, []int64{1, 2, 4}, idsOnRef(t, tbl,
table.MainBranch))
+
+ txn, err := tbl.NewTransactionOnBranch(branchPlanningRef)
+ require.NoError(t, err)
+ reader := idRecordReader(t, tbl, 1, 5)
+ require.NoError(t, txn.DynamicPartitionOverwrite(t.Context(), reader,
nil))
+ reader.Release()
+ updated, err := txn.Commit(t.Context())
+ require.NoError(t, err)
+
+ require.ElementsMatch(t, []int64{1, 2, 3, 5}, idsOnRef(t, updated,
branchPlanningRef))
+ require.ElementsMatch(t, []int64{1, 2, 4}, idsOnRef(t, updated,
table.MainBranch))
+}
+
+func TestDynamicPartitionOverwriteMissingBranchPinsMain(t *testing.T) {
+ base, _ := newDynamicOverwriteTable(t)
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+ stale, err := tbl.NewTransactionOnBranch("audit")
+ require.NoError(t, err)
+ reader := idRecordReader(t, tbl, 1)
+ require.NoError(t, stale.DynamicPartitionOverwrite(t.Context(), reader,
nil))
+ reader.Release()
+
+ peer := appendRowsOnRef(t, tbl, table.MainBranch, 1)
+ _, err = stale.Commit(t.Context())
+ require.ErrorIs(t, err, table.ErrCommitFailed)
+ assert.ElementsMatch(t, []int64{1, 1, 2}, idsOnRef(t, peer,
table.MainBranch))
+}
+
+func TestDynamicPartitionOverwriteBucketTransformWithFiles(t *testing.T) {
+ spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name: "id_bucket",
Transform: iceberg.BucketTransform{NumBuckets: 256},
+ })
+ base, _ := newDynamicOverwriteTableWithSpec(t, spec)
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+ reader := idRecordReader(t, tbl, 1, 3)
+ updated, err := tbl.DynamicPartitionOverwrite(t.Context(), reader, nil)
+ reader.Release()
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []int64{1, 2, 3}, idsOnRef(t, updated,
table.MainBranch))
+}
+
+func TestDynamicPartitionOverwriteV3ArrowInheritsRowIDs(t *testing.T) {
+ spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name: "id", Transform:
iceberg.IdentityTransform{},
+ })
+ base, _ := newDynamicOverwriteTableWithFormat(t, spec, "3")
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+
+ reader := idRecordReader(t, tbl, 1, 3)
+ updated, err := tbl.DynamicPartitionOverwrite(t.Context(), reader, nil)
+ reader.Release()
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []int64{1, 2, 3}, idsOnRef(t, updated,
table.MainBranch))
+
+ head := updated.SnapshotByName(table.MainBranch)
+ require.NotNil(t, head)
+ require.NotNil(t, head.FirstRowID)
+ require.NotNil(t, head.AddedRows)
+ assert.Positive(t, *head.AddedRows)
+}
+
+func dynamicOverwriteJSONReader(t *testing.T, tbl *table.Table, rows string)
array.RecordReader {
+ t.Helper()
+ arrowSchema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+ data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema,
[]string{rows})
+ require.NoError(t, err)
+ t.Cleanup(data.Release)
+
+ return array.NewTableReader(data, -1)
+}
+
+// A null source value must be stored as a null partition tuple, and a
+// replacement of that partition must leave non-null partitions untouched.
+func TestDynamicPartitionOverwriteNullIdentityPartition(t *testing.T) {
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ iceberg.NestedField{ID: 2, Name: "group", Type:
iceberg.PrimitiveTypes.Int32},
+ )
+ spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{2}, FieldID: 1000, Name: "group", Transform:
iceberg.IdentityTransform{},
+ })
+ tbl, _ := newDynamicOverwriteTableWithSchema(t, schema, spec, "2")
+
+ seed := dynamicOverwriteJSONReader(t, tbl,
`[{"id":1,"group":null},{"id":2,"group":7},{"id":3,"group":null}]`)
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.Append(t.Context(), seed, nil))
+ seed.Release()
+ tbl, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ require.ElementsMatch(t, []int64{1, 2, 3}, idsOnRef(t, tbl,
table.MainBranch))
+
+ replacement := dynamicOverwriteJSONReader(t, tbl,
`[{"id":4,"group":null},{"id":5,"group":null}]`)
+ updated, err := tbl.DynamicPartitionOverwrite(t.Context(), replacement,
nil)
+ replacement.Release()
+ require.NoError(t, err)
+ require.ElementsMatch(t, []int64{2, 4, 5}, idsOnRef(t, updated,
table.MainBranch))
+}
+
+// The manifest decoder and Arrow writer can represent day partitions with
+// different Go types. An overwrite must still match their logical values.
+func TestDynamicPartitionOverwriteDatePartition(t *testing.T) {
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ iceberg.NestedField{ID: 2, Name: "event_date", Type:
iceberg.PrimitiveTypes.Date, Required: true},
+ )
+ spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{2}, FieldID: 1000, Name: "event_day",
Transform: iceberg.DayTransform{},
+ })
+ tbl, _ := newDynamicOverwriteTableWithSchema(t, schema, spec, "2")
+
+ seed := dynamicOverwriteJSONReader(t, tbl,
`[{"id":1,"event_date":"2026-02-10"},{"id":2,"event_date":"2026-02-11"},{"id":3,"event_date":"2026-02-10"}]`)
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.Append(t.Context(), seed, nil))
+ seed.Release()
+ tbl, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ require.ElementsMatch(t, []int64{1, 2, 3}, idsOnRef(t, tbl,
table.MainBranch))
+
+ replacement := dynamicOverwriteJSONReader(t, tbl,
`[{"id":4,"event_date":"2026-02-10"}]`)
+ updated, err := tbl.DynamicPartitionOverwrite(t.Context(), replacement,
nil)
+ replacement.Release()
+ require.NoError(t, err)
+ require.ElementsMatch(t, []int64{2, 4}, idsOnRef(t, updated,
table.MainBranch))
+}
+
+// newDynamicOverwriteV3MoRTable writes two rows in each partition so a
+// partial DELETE creates a deletion vector instead of dropping a whole file.
+func newDynamicOverwriteV3MoRTable(t *testing.T) (*table.Table,
*concurrentTestCatalog) {
+ t.Helper()
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ iceberg.NestedField{ID: 2, Name: "group", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ )
+ spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{2}, FieldID: 1000, Name: "group", Transform:
iceberg.IdentityTransform{},
+ })
+ tbl, cat := newDynamicOverwriteTableWithSchema(t, schema, spec, "3")
+ setMode := tbl.NewTransaction()
+ require.NoError(t,
setMode.SetProperties(iceberg.Properties{table.WriteDeleteModeKey:
table.WriteModeMergeOnRead}))
+ var err error
+ tbl, err = setMode.Commit(t.Context())
+ require.NoError(t, err)
+ seed := dynamicOverwriteJSONReader(t, tbl,
+
`[{"id":1,"group":10},{"id":2,"group":10},{"id":3,"group":20},{"id":4,"group":20}]`)
+ tbl, err = tbl.Append(t.Context(), seed, nil)
+ seed.Release()
+ require.NoError(t, err)
+ tasks, err := tbl.Scan().PlanFiles(t.Context())
+ require.NoError(t, err)
+ require.Len(t, tasks, 2, "each partition needs its own data file")
+
+ return tbl, cat
+}
+
+// The overwrite must remove the DV tied to the replaced data file, preserve
+// the DV for the untouched partition, and preserve row IDs for surviving rows.
+func TestDynamicPartitionOverwriteV3RemovesOnlyTouchedDeletionVectors(t
*testing.T) {
+ tbl, _ := newDynamicOverwriteV3MoRTable(t)
+ originalRowIDs := rowIDByID(t, tbl)
+ var err error
+ for _, id := range []int64{1, 3} {
+ tbl, err = tbl.Delete(t.Context(),
iceberg.EqualTo(iceberg.Reference("id"), id), nil)
+ require.NoError(t, err)
+ }
+ require.Equal(t, 2, liveDVCountOnRef(t, tbl, table.MainBranch))
+ require.ElementsMatch(t, []int64{2, 4}, idsOnRef(t, tbl,
table.MainBranch))
+ beforeData, _ := liveFilesOnRef(t, tbl, table.MainBranch)
+ require.Len(t, beforeData, 2)
+ replacement := dynamicOverwriteJSONReader(t, tbl,
`[{"id":5,"group":10}]`)
+ updated, err := tbl.DynamicPartitionOverwrite(t.Context(), replacement,
nil)
+ replacement.Release()
+ require.NoError(t, err)
+ require.ElementsMatch(t, []int64{4, 5}, idsOnRef(t, updated,
table.MainBranch))
+ require.Equal(t, 1, liveDVCountOnRef(t, updated, table.MainBranch))
+ afterData, _ := liveFilesOnRef(t, updated, table.MainBranch)
+ require.Len(t, afterData, 2)
+ var retained, replaced int
+ for _, path := range beforeData {
+ refs := map[string]struct{}{path: {}}
+ if slices.Contains(afterData, path) {
+ retained++
+ require.Len(t, deleteEntriesReferencing(t, updated,
refs), 1,
+ "the untouched data file must keep its deletion
vector")
+ } else {
+ replaced++
+ require.Empty(t, deleteEntriesReferencing(t, updated,
refs),
+ "the replaced data file must not keep its
deletion vector")
+ }
+ }
+ require.Equal(t, 1, retained)
+ require.Equal(t, 1, replaced)
+ rowIDs := rowIDByID(t, updated)
+ require.Equal(t, originalRowIDs[4], rowIDs[4], "untouched rows must
keep their _row_id")
+ require.Greater(t, rowIDs[5],
+ max(originalRowIDs[1], originalRowIDs[2], originalRowIDs[3],
originalRowIDs[4]),
+ "the replacement row must receive a fresh _row_id")
+}
+
+// Concurrent deletion vectors in replaced partitions must conflict, while
+// ones in other partitions must survive a refresh-and-replay of the overwrite.
+func TestDynamicPartitionOverwriteConcurrentDeletionVectors(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ deleteID int64
+ conflict bool
+ }{
+ {name: "touched partition", deleteID: 1, conflict: true},
+ {name: "other partition", deleteID: 3},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ tbl, cat := newDynamicOverwriteV3MoRTable(t)
+ stale := tbl.NewTransaction()
+ replacement := dynamicOverwriteJSONReader(t, tbl,
`[{"id":5,"group":10}]`)
+ require.NoError(t,
stale.DynamicPartitionOverwrite(t.Context(), replacement, nil))
+ replacement.Release()
+ peer, err := tbl.Delete(t.Context(),
iceberg.EqualTo(iceberg.Reference("id"), tc.deleteID), nil)
+ require.NoError(t, err)
+ require.Equal(t, 1, liveDVCountOnRef(t, peer,
table.MainBranch),
+ "concurrent writer must add a deletion vector")
+ attemptsBefore := cat.attempts.Load()
+ updated, err := stale.Commit(t.Context())
+ if tc.conflict {
+ require.ErrorIs(t, err,
table.ErrConflictingDeleteFiles)
+ require.Equal(t, attemptsBefore+1,
cat.attempts.Load())
+ require.ElementsMatch(t, []int64{2, 3, 4},
idsOnRef(t, peer, table.MainBranch))
+
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, attemptsBefore+2, cat.attempts.Load(),
+ "the independent delete must survive a refresh
and retry")
+ require.ElementsMatch(t, []int64{4, 5}, idsOnRef(t,
updated, table.MainBranch))
+ require.Equal(t, 1, liveDVCountOnRef(t, updated,
table.MainBranch))
+ })
+ }
+}
+
+// Count data files recursively to catch writes in existing partition
+// subdirectories as well as new ones.
+func dynamicOverwriteDataPaths(t *testing.T, tbl *table.Table) []string {
+ t.Helper()
+ var paths []string
+ err := filepath.WalkDir(filepath.Join(tbl.Location(), "data"),
func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if !entry.IsDir() {
+ paths = append(paths, filepath.ToSlash(path))
+ }
+
+ return nil
+ })
+ require.NoError(t, err)
+ slices.Sort(paths)
+
+ return paths
+}
+
+// Existing files use their own spec ID in the partition key, so an Arrow
+// overwrite with the current spec leaves old-spec rows untouched.
+func TestDynamicPartitionOverwriteArrowKeepsOldSpecFiles(t *testing.T) {
+ base, _ := newDynamicOverwriteTable(t)
+ tbl := appendRowsOnRef(t, base, table.MainBranch, 1, 2)
+ evolve := tbl.NewTransaction()
+ require.NoError(t, evolve.UpdateSpec(true).
+ RemoveField("id").
+ AddField("id", iceberg.BucketTransform{NumBuckets: 16},
"id_bucket").
+ Commit())
+ var err error
+ tbl, err = evolve.Commit(t.Context())
+ require.NoError(t, err)
+ before := dynamicOverwriteDataPaths(t, tbl)
+ require.NotEmpty(t, before, "test requires already-written data files")
+ replacement := idRecordReader(t, tbl, 3)
+ updated, err := tbl.DynamicPartitionOverwrite(t.Context(), replacement,
nil)
+ replacement.Release()
+ require.NoError(t, err)
+ require.Len(t, dynamicOverwriteDataPaths(t, updated), len(before)+1)
+ require.ElementsMatch(t, []int64{1, 2, 3}, idsOnRef(t, updated,
table.MainBranch))
+}
diff --git a/table/dynamic_partition_overwrite_test.go
b/table/dynamic_partition_overwrite_test.go
new file mode 100644
index 000000000..774c7ce49
--- /dev/null
+++ b/table/dynamic_partition_overwrite_test.go
@@ -0,0 +1,511 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package table
+
+import (
+ "context"
+ "slices"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ iceio "github.com/apache/iceberg-go/io"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDynamicPartitionMatcher(t *testing.T) {
+ spec := iceberg.NewPartitionSpecID(7,
+ iceberg.PartitionField{
+ SourceIDs: []int{1},
+ FieldID: 1000,
+ Name: "bucket_id",
+ Transform: iceberg.BucketTransform{NumBuckets: 16},
+ },
+ iceberg.PartitionField{
+ SourceIDs: []int{2},
+ FieldID: 1001,
+ Name: "region",
+ Transform: iceberg.IdentityTransform{},
+ },
+ )
+
+ files := []iceberg.DataFile{
+ newTestDataFile(t, spec, "file://new-1.parquet",
+ map[int]any{1000: int32(4), 1001: "north"}),
+ newTestDataFile(t, spec, "file://new-2.parquet",
+ map[int]any{1000: int32(4), 1001: "north"}),
+ newTestDataFile(t, spec, "file://new-3.parquet",
+ map[int]any{1000: int32(5), 1001: "south"}),
+ }
+ keys, err := dynamicPartitionKeys(files)
+ require.NoError(t, err)
+ require.Len(t, keys, 2)
+ for _, tc := range []struct {
+ partition map[int]any
+ want bool
+ }{
+ {map[int]any{1000: int32(4), 1001: "north"}, true},
+ {map[int]any{1000: int64(5), 1001: "south"}, true},
+ {map[int]any{1000: int32(4), 1001: "south"}, false},
+ {map[int]any{1000: int32(7), 1001: "north"}, false},
+ } {
+ key, err := canonicalPartitionKey(int32(spec.ID()),
tc.partition)
+ require.NoError(t, err)
+ _, found := keys[key]
+ require.Equal(t, tc.want, found)
+ }
+}
+
+func TestDynamicPartitionOverwriteDataFiles(t *testing.T) {
+ ctx := context.Background()
+ spec := partitionedSpec()
+ seed, fs := createTestTransactionWithMemIO(t, spec)
+
+ original := []iceberg.DataFile{
+ newTestDataFile(t, spec, "file://old-a.parquet",
map[int]any{1000: int32(1)}),
+ newTestDataFile(t, spec, "file://old-a2.parquet",
map[int]any{1000: int32(1)}),
+ newTestDataFile(t, spec, "file://old-b.parquet",
map[int]any{1000: int32(2)}),
+ }
+ require.NoError(t, seed.AddDataFiles(ctx, original, nil))
+ seedMeta, err := seed.meta.Build()
+ require.NoError(t, err)
+ tbl := New(Identifier{"db", "tbl"}, seedMeta, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return fs, nil }, nil)
+
+ txn := tbl.NewTransaction()
+ replacement := []iceberg.DataFile{
+ newTestDataFile(t, spec, "file://new-a.parquet",
map[int]any{1000: int32(1)}),
+ newTestDataFile(t, spec, "file://new-a2.parquet",
map[int]any{1000: int32(1)}),
+ }
+
+ require.NoError(t, txn.DynamicPartitionOverwriteDataFiles(ctx,
replacement,
+ iceberg.Properties{"source": "dynamic-test"}))
+
+ updated, err := txn.meta.Build()
+ require.NoError(t, err)
+ snapshot := updated.CurrentSnapshot()
+ require.NotNil(t, snapshot)
+ require.NotNil(t, snapshot.Summary)
+ require.Equal(t, OpOverwrite, snapshot.Summary.Operation)
+ require.Equal(t, "true",
snapshot.Summary.Properties["replace-partitions"])
+ require.Equal(t, "dynamic-test", snapshot.Summary.Properties["source"])
+
+ var paths []string
+ for df, err := range snapshot.dataFiles(ctx, fs, nil) {
+ require.NoError(t, err)
+ if df.ContentType() == iceberg.EntryContentData {
+ paths = append(paths, df.FilePath())
+ }
+ }
+ slices.Sort(paths)
+ require.Equal(t, []string{"file://new-a.parquet",
"file://new-a2.parquet", "file://old-b.parquet"}, paths)
+ require.NotContains(t, txn.pinnedRefs, MainBranch, "existing branches
use partition-scoped conflict validation")
+}
+
+func TestDynamicPartitionOverwriteRejectsAlreadyLivePath(t *testing.T) {
+ spec := partitionedSpec()
+ seed, fs := createTestTransactionWithMemIO(t, spec)
+ existing := newTestDataFile(t, spec, "file://existing.parquet",
map[int]any{1000: int32(1)})
+ require.NoError(t, seed.AddDataFiles(t.Context(),
[]iceberg.DataFile{existing}, nil))
+ seedMeta, err := seed.meta.Build()
+ require.NoError(t, err)
+ tbl := New(Identifier{"db", "tbl"}, seedMeta, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return fs, nil }, nil)
+
+ txn := tbl.NewTransaction()
+ err = txn.DynamicPartitionOverwriteDataFiles(t.Context(),
[]iceberg.DataFile{existing}, nil)
+ require.ErrorIs(t, err, ErrInvalidOperation)
+ require.ErrorContains(t, err, "already exists")
+ require.Empty(t, txn.meta.updates)
+ require.Empty(t, txn.pinnedRefs)
+}
+
+func TestDynamicPartitionOverwriteAppendsNewPartition(t *testing.T) {
+ ctx := context.Background()
+ spec := partitionedSpec()
+ txn, fs := createTestTransactionWithMemIO(t, spec)
+ newFile := newTestDataFile(t, spec, "file://first.parquet",
map[int]any{1000: int32(10)})
+
+ require.NoError(t, txn.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{newFile}, nil))
+ metadata, err := txn.meta.Build()
+ require.NoError(t, err)
+ snapshot := metadata.CurrentSnapshot()
+ require.NotNil(t, snapshot)
+ var files []string
+ for df, err := range snapshot.dataFiles(ctx, fs, nil) {
+ require.NoError(t, err)
+ files = append(files, df.FilePath())
+ }
+ require.Equal(t, []string{"file://first.parquet"}, files)
+ require.Equal(t, OpOverwrite, snapshot.Summary.Operation)
+ require.Equal(t, "true",
snapshot.Summary.Properties["replace-partitions"])
+ require.Contains(t, txn.pinnedRefs, MainBranch)
+}
+
+func TestDynamicPartitionOverwriteUnpartitionedReplacesAllFiles(t *testing.T) {
+ ctx := context.Background()
+ spec := iceberg.NewPartitionSpec()
+ seed, fs := createTestTransactionWithMemIO(t, spec)
+ original := []iceberg.DataFile{
+ newTestDataFile(t, spec, "file://old-1.parquet", nil),
+ newTestDataFile(t, spec, "file://old-2.parquet", nil),
+ }
+ require.NoError(t, seed.AddDataFiles(ctx, original, nil))
+ seedMeta, err := seed.meta.Build()
+ require.NoError(t, err)
+ tbl := New(Identifier{"db", "tbl"}, seedMeta, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return fs, nil }, nil)
+
+ txn := tbl.NewTransaction()
+ replacement := newTestDataFile(t, spec, "file://replacement.parquet",
nil)
+ require.NoError(t, txn.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{replacement}, nil))
+ updated, err := txn.meta.Build()
+ require.NoError(t, err)
+ snapshot := updated.CurrentSnapshot()
+ require.Equal(t, OpOverwrite, snapshot.Summary.Operation)
+ require.Equal(t, "true",
snapshot.Summary.Properties["replace-partitions"])
+ var paths []string
+ for df, err := range snapshot.dataFiles(ctx, fs, nil) {
+ require.NoError(t, err)
+ if df.ContentType() == iceberg.EntryContentData {
+ paths = append(paths, df.FilePath())
+ }
+ }
+ require.Equal(t, []string{"file://replacement.parquet"}, paths)
+}
+
+func TestDynamicPartitionOverwriteEmptyInputIsNoOp(t *testing.T) {
+ txn, _ := createTestTransactionWithMemIO(t, partitionedSpec())
+ require.NoError(t, txn.DynamicPartitionOverwriteDataFiles(t.Context(),
nil, nil))
+ require.Empty(t, txn.meta.updates)
+ require.Empty(t, txn.pinnedRefs)
+}
+
+func TestDynamicPartitionOverwriteRespectsCanceledContext(t *testing.T) {
+ spec := partitionedSpec()
+ txn, _ := createTestTransactionWithMemIO(t, spec)
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+
+ file := newTestDataFile(t, spec, "file://canceled.parquet",
map[int]any{1000: int32(5)})
+ err := txn.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{file}, nil)
+ require.ErrorIs(t, err, context.Canceled)
+ require.Empty(t, txn.meta.updates)
+ require.Empty(t, txn.pinnedRefs)
+}
+
+func TestDynamicPartitionOverwriteV3RowLineage(t *testing.T) {
+ ctx := context.Background()
+ spec := partitionedSpec()
+ txn, _ := createTestTransactionWithMemIO(t, spec)
+ require.NoError(t, txn.meta.SetFormatVersion(3))
+ file := newTestDataFile(t, spec, "file://v3.parquet", map[int]any{1000:
int32(1)})
+ require.NoError(t, txn.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{file}, nil))
+ meta, err := txn.meta.Build()
+ require.NoError(t, err)
+ snapshot := meta.CurrentSnapshot()
+ require.NotNil(t, snapshot)
+ require.Equal(t, OpOverwrite, snapshot.Summary.Operation)
+ require.NotNil(t, snapshot.FirstRowID)
+ require.NotNil(t, snapshot.AddedRows)
+}
+
+func TestDynamicPartitionOverwriteRejectsUnsupportedLayouts(t *testing.T) {
+ ctx := context.Background()
+ t.Run("wrong incoming spec", func(t *testing.T) {
+ spec := partitionedSpec()
+ txn, _ := createTestTransactionWithMemIO(t, spec)
+ otherSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1001, Name: "bucket_id",
Transform: iceberg.BucketTransform{NumBuckets: 16},
+ })
+ require.NoError(t, txn.meta.AddPartitionSpec(&otherSpec, false))
+ require.NoError(t, txn.meta.SetDefaultSpecID(-1))
+ updatesBefore := slices.Clone(txn.meta.updates)
+ file := newTestDataFile(t, spec, "file://wrong-spec.parquet",
map[int]any{1000: int32(1)})
+ err := txn.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{file}, nil)
+ require.ErrorIs(t, err, ErrInvalidOperation)
+ require.ErrorContains(t, err, "current spec")
+ require.Equal(t, updatesBefore, txn.meta.updates)
+ })
+
+ t.Run("nil transaction", func(t *testing.T) {
+ var txn *Transaction
+ require.Error(t, txn.DynamicPartitionOverwriteDataFiles(ctx,
nil, nil))
+ })
+}
+
+func TestDynamicPartitionOverwriteV1(t *testing.T) {
+ ctx := t.Context()
+ spec := partitionedSpec()
+ seed, fs := createTestTransactionWithMemIO(t, spec)
+ seed.meta.formatVersion = 1
+ old := []iceberg.DataFile{
+ newTestDataFile(t, spec, "file://v1-old-a.parquet",
map[int]any{1000: int32(1)}),
+ newTestDataFile(t, spec, "file://v1-old-b.parquet",
map[int]any{1000: int32(2)}),
+ }
+ require.NoError(t, seed.AddDataFiles(ctx, old, nil))
+ base, err := seed.meta.Build()
+ require.NoError(t, err)
+ require.Equal(t, 1, base.FormatVersion())
+ tbl := New(Identifier{"db", "tbl"}, base, "metadata.json",
func(context.Context) (iceio.IO, error) { return fs, nil }, nil)
+ tx := tbl.NewTransaction()
+ replacement := newTestDataFile(t, spec, "file://v1-new-a.parquet",
map[int]any{1000: int32(1)})
+ require.NoError(t, tx.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{replacement}, nil))
+ updated, err := tx.meta.Build()
+ require.NoError(t, err)
+ snapshot := updated.CurrentSnapshot()
+ require.NotNil(t, snapshot)
+ require.Equal(t, OpOverwrite, snapshot.Summary.Operation)
+ var paths []string
+ for file, err := range snapshot.dataFiles(ctx, fs, nil) {
+ require.NoError(t, err)
+ if file.ContentType() == iceberg.EntryContentData {
+ paths = append(paths, file.FilePath())
+ }
+ }
+ require.ElementsMatch(t, []string{"file://v1-new-a.parquet",
"file://v1-old-b.parquet"}, paths)
+}
+
+func TestDynamicPartitionOverwriteKeepsLiveOldSpec(t *testing.T) {
+ ctx := t.Context()
+ oldSpec := partitionedSpec()
+ seed, fs := createTestTransactionWithMemIO(t, oldSpec)
+ old := newTestDataFile(t, oldSpec, "file://old-spec.parquet",
map[int]any{1000: int32(1)})
+ require.NoError(t, seed.AddDataFiles(ctx, []iceberg.DataFile{old}, nil))
+ oldDelete := newTestPosDeleteFileForSpec(t, oldSpec,
"file://old-spec-delete.parquet",
+ map[int]any{1000: int32(1)}, old.FilePath())
+ require.NoError(t,
seed.NewRowDelta(nil).AddDeletes(oldDelete).Commit(ctx))
+ base, err := seed.meta.Build()
+ require.NoError(t, err)
+
+ tbl := New(Identifier{"db", "tbl"}, base, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return fs, nil }, nil)
+ evolve := tbl.NewTransaction()
+ nextSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1001, Name: "bucket_id",
Transform: iceberg.BucketTransform{NumBuckets: 16},
+ })
+ require.NoError(t, evolve.meta.AddPartitionSpec(&nextSpec, false))
+ require.NoError(t, evolve.meta.SetDefaultSpecID(-1))
+ evolved, err := evolve.meta.Build()
+ require.NoError(t, err)
+
+ txn := New(Identifier{"db", "tbl"}, evolved, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return fs, nil },
nil).NewTransaction()
+ currentSpec := evolved.PartitionSpec()
+ var partitionFieldID int
+ for _, field := range currentSpec.Fields() {
+ partitionFieldID = field.FieldID
+
+ break
+ }
+ require.NotZero(t, partitionFieldID)
+ replacement := newTestDataFile(t, currentSpec,
"file://new-spec.parquet",
+ map[int]any{partitionFieldID: int32(1)})
+ require.NoError(t, txn.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{replacement}, nil))
+ updated, err := txn.meta.Build()
+ require.NoError(t, err)
+ snapshot := updated.CurrentSnapshot()
+ require.NotNil(t, snapshot)
+
+ var liveData []string
+ for file, err := range snapshot.dataFiles(ctx, fs, nil) {
+ require.NoError(t, err)
+ if file.ContentType() == iceberg.EntryContentData {
+ liveData = append(liveData, file.FilePath())
+ }
+ }
+ require.ElementsMatch(t, []string{"file://old-spec.parquet",
"file://new-spec.parquet"}, liveData)
+
+ var liveDeletes []string
+ for entry, err := range snapshot.entries(ctx, fs,
iceberg.ManifestContentDeletes, true) {
+ require.NoError(t, err)
+ liveDeletes = append(liveDeletes, entry.DataFile().FilePath())
+ }
+ require.Equal(t, []string{"file://old-spec-delete.parquet"},
liveDeletes)
+}
+
+func TestDynamicPartitionOverwriteUnpartitionedAfterSpecEvolution(t
*testing.T) {
+ ctx := t.Context()
+ oldSpec := partitionedSpec()
+ seed, fs := createTestTransactionWithMemIO(t, oldSpec)
+ oldData := newTestDataFile(t, oldSpec, "file://old-spec.parquet",
map[int]any{1000: int32(1)})
+ require.NoError(t, seed.AddDataFiles(ctx, []iceberg.DataFile{oldData},
nil))
+ base, err := seed.meta.Build()
+ require.NoError(t, err)
+
+ fsF := func(context.Context) (iceio.IO, error) { return fs, nil }
+ evolve := New(Identifier{"db", "tbl"}, base, "metadata.json", fsF,
nil).NewTransaction()
+ emptySpec := iceberg.NewPartitionSpec()
+ require.NoError(t, evolve.meta.AddPartitionSpec(&emptySpec, false))
+ require.NoError(t, evolve.meta.SetDefaultSpecID(-1))
+ evolved, err := evolve.meta.Build()
+ require.NoError(t, err)
+ currentSpec := evolved.PartitionSpec()
+ require.True(t, currentSpec.IsUnpartitioned())
+
+ // Equality deletes written with an unpartitioned spec are global.
+ builder, err := iceberg.NewDataFileBuilder(currentSpec,
iceberg.EntryContentEqDeletes,
+ "file://global-delete.parquet", iceberg.ParquetFile, nil, nil,
nil, 1, 1)
+ require.NoError(t, err)
+ eqDelete := builder.EqualityFieldIDs([]int{1}).Build()
+ addDelete := New(Identifier{"db", "tbl"}, evolved, "metadata.json",
fsF, nil).NewTransaction()
+ require.NoError(t,
addDelete.NewRowDelta(nil).AddDeletes(eqDelete).Commit(ctx))
+ withDelete, err := addDelete.meta.Build()
+ require.NoError(t, err)
+
+ overwrite := New(Identifier{"db", "tbl"}, withDelete, "metadata.json",
fsF, nil).NewTransaction()
+ replacement := newTestDataFile(t, currentSpec,
"file://replacement.parquet", nil)
+ require.NoError(t, overwrite.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{replacement}, nil))
+ updated, err := overwrite.meta.Build()
+ require.NoError(t, err)
+
+ var dataPaths []string
+ for df, err := range updated.CurrentSnapshot().dataFiles(ctx, fs, nil) {
+ require.NoError(t, err)
+ dataPaths = append(dataPaths, df.FilePath())
+ }
+ require.Equal(t, []string{"file://replacement.parquet"}, dataPaths,
+ "unpartitioned overwrite must replace data under old specs too")
+
+ var deletePaths []string
+ for entry, err := range updated.CurrentSnapshot().entries(ctx, fs,
iceberg.ManifestContentDeletes, true) {
+ require.NoError(t, err)
+ deletePaths = append(deletePaths, entry.DataFile().FilePath())
+ }
+ require.Empty(t, deletePaths, "no global delete may survive a
full-table replacement")
+}
+
+func TestDynamicPartitionOverwriteKeepsDeleteForSurvivingOldSpecData(t
*testing.T) {
+ ctx := t.Context()
+ oldSpec := partitionedSpec()
+ seed, fs := createTestTransactionWithMemIO(t, oldSpec)
+ oldData := newTestDataFile(t, oldSpec, "file://old-spec.parquet",
map[int]any{1000: int32(1)})
+ require.NoError(t, seed.AddDataFiles(ctx, []iceberg.DataFile{oldData},
nil))
+ base, err := seed.meta.Build()
+ require.NoError(t, err)
+
+ fsF := func(context.Context) (iceio.IO, error) { return fs, nil }
+ evolve := New(Identifier{"db", "tbl"}, base, "metadata.json", fsF,
nil).NewTransaction()
+ newSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1001, Name: "id_bucket",
Transform: iceberg.BucketTransform{NumBuckets: 16},
+ })
+ require.NoError(t, evolve.meta.AddPartitionSpec(&newSpec, false))
+ require.NoError(t, evolve.meta.SetDefaultSpecID(-1))
+ evolved, err := evolve.meta.Build()
+ require.NoError(t, err)
+ currentSpec := evolved.PartitionSpec()
+ var fieldID int
+ for _, field := range currentSpec.Fields() {
+ fieldID = field.FieldID
+
+ break
+ }
+ require.NotZero(t, fieldID)
+ nullPartition := map[int]any{fieldID: nil}
+
+ // A v2 position delete can target an old-spec data file even if its
+ // manifest stores it under a new partition tuple with a null value.
+ posDelete := newTestPosDeleteFileForSpec(t, currentSpec,
"file://old-spec-delete.parquet",
+ nullPartition, oldData.FilePath())
+ addDelete := New(Identifier{"db", "tbl"}, evolved, "metadata.json",
fsF, nil).NewTransaction()
+ require.NoError(t,
addDelete.NewRowDelta(nil).AddDeletes(posDelete).Commit(ctx))
+ withDelete, err := addDelete.meta.Build()
+ require.NoError(t, err)
+
+ overwrite := New(Identifier{"db", "tbl"}, withDelete, "metadata.json",
fsF, nil).NewTransaction()
+ replacement := newTestDataFile(t, currentSpec,
"file://new-spec.parquet", nullPartition)
+ require.NoError(t, overwrite.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{replacement}, nil))
+ updated, err := overwrite.meta.Build()
+ require.NoError(t, err)
+
+ var dataPaths []string
+ for df, err := range updated.CurrentSnapshot().dataFiles(ctx, fs, nil) {
+ require.NoError(t, err)
+ if df.ContentType() == iceberg.EntryContentData {
+ dataPaths = append(dataPaths, df.FilePath())
+ }
+ }
+ require.ElementsMatch(t, []string{oldData.FilePath(),
replacement.FilePath()}, dataPaths)
+
+ var deletePaths []string
+ for entry, err := range updated.CurrentSnapshot().entries(ctx, fs,
iceberg.ManifestContentDeletes, true) {
+ require.NoError(t, err)
+ deletePaths = append(deletePaths, entry.DataFile().FilePath())
+ }
+ require.Equal(t, []string{posDelete.FilePath()}, deletePaths,
+ "deletes for surviving old-spec data must not be dropped")
+}
+
+func TestDynamicPartitionOverwriteWithoutAutoNameMapping(t *testing.T) {
+ ctx := t.Context()
+ spec := partitionedSpec()
+ txn, _ := createTestTransactionWithMemIO(t, spec)
+ file := newTestDataFile(t, spec, "file://no-name-mapping.parquet",
map[int]any{1000: int32(1)})
+ require.NoError(t, txn.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{file}, nil, WithoutAutoNameMapping()))
+ updated, err := txn.meta.Build()
+ require.NoError(t, err)
+ require.Nil(t, updated.NameMapping())
+}
+
+func TestDynamicPartitionOverwriteRejectsStagedDefaultSpecChange(t *testing.T)
{
+ baseSpec := iceberg.NewPartitionSpec()
+ txn, _ := createTestTransactionWithMemIO(t, baseSpec)
+ changedSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name: "id", Transform:
iceberg.IdentityTransform{},
+ })
+ require.NoError(t, txn.meta.AddPartitionSpec(&changedSpec, false))
+ require.NoError(t, txn.meta.SetDefaultSpecID(-1))
+ spec, err := txn.meta.CurrentSpec()
+ require.NoError(t, err)
+ file := newTestDataFile(t, *spec, "file://staged-spec.parquet",
+ map[int]any{1000: int32(1)})
+ err = txn.DynamicPartitionOverwriteDataFiles(t.Context(),
[]iceberg.DataFile{file}, nil)
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.ErrorContains(t, err, "default spec id has changed")
+}
+
+func TestDynamicPartitionOverwriteRemovesOnlyTouchedPartitionDeletes(t
*testing.T) {
+ ctx := t.Context()
+ spec := partitionedSpec()
+ seed, fs := createTestTransactionWithMemIO(t, spec)
+ dataA := newTestDataFile(t, spec, "file://old-a.parquet",
map[int]any{1000: int32(1)})
+ dataB := newTestDataFile(t, spec, "file://old-b.parquet",
map[int]any{1000: int32(2)})
+ require.NoError(t, seed.AddDataFiles(ctx, []iceberg.DataFile{dataA,
dataB}, nil))
+ base, err := seed.meta.Build()
+ require.NoError(t, err)
+ fsF := func(context.Context) (iceio.IO, error) { return fs, nil }
+ tbl := New(Identifier{"db", "tbl"}, base, "metadata.json", fsF, nil)
+
+ deleteA := newTestPosDeleteFileForSpec(t, spec,
"file://delete-a.parquet", map[int]any{1000: int32(1)}, dataA.FilePath())
+ deleteB := newTestPosDeleteFileForSpec(t, spec,
"file://delete-b.parquet", map[int]any{1000: int32(2)}, dataB.FilePath())
+ addDeletes := tbl.NewTransaction()
+ require.NoError(t, addDeletes.NewRowDelta(nil).AddDeletes(deleteA,
deleteB).Commit(ctx))
+ withDeletes, err := addDeletes.meta.Build()
+ require.NoError(t, err)
+
+ overwrite := New(Identifier{"db", "tbl"}, withDeletes, "metadata.json",
fsF, nil).NewTransaction()
+ replacement := newTestDataFile(t, spec, "file://new-a.parquet",
map[int]any{1000: int32(1)})
+ require.NoError(t, overwrite.DynamicPartitionOverwriteDataFiles(ctx,
[]iceberg.DataFile{replacement}, nil))
+ updated, err := overwrite.meta.Build()
+ require.NoError(t, err)
+ snapshot := updated.CurrentSnapshot()
+ var liveDeletes []string
+ for entry, err := range snapshot.entries(ctx, fs,
iceberg.ManifestContentDeletes, true) {
+ require.NoError(t, err)
+ liveDeletes = append(liveDeletes, entry.DataFile().FilePath())
+ }
+ require.Equal(t, []string{"file://delete-b.parquet"}, liveDeletes)
+}
diff --git a/table/internal/partition_predicate.go
b/table/internal/partition_predicate.go
index 609d5e627..c47ceecdc 100644
--- a/table/internal/partition_predicate.go
+++ b/table/internal/partition_predicate.go
@@ -29,10 +29,10 @@ import (
)
// BuildPartitionMatchPredicate constructs a BooleanExpression matching all
rows
-// that belong to any of the given partitions. It is the core of dynamic
-// partition overwrite (see https://github.com/apache/iceberg-go/issues/1216):
-// the returned expression selects the existing data to delete before the new
-// data is appended.
+// that belong to any of the given partitions. It can be used by row-filter
+// operations involving partition transforms (issue #1216), but dynamic
+// partition overwrite now matches stored partition tuples directly.
+// Partial-file rewrites still need strict partition matching (issue #1215).
//
// partitions holds partition tuples as returned by DataFile.Partition(), each
// keyed by partition-field ID and carrying the field's (transformed) value.
The
@@ -46,7 +46,7 @@ import (
//
// Duplicate tuples collapse to a single clause, and an empty input yields
// AlwaysFalse (matching nothing). Callers are expected to pass a partitioned
-// spec; dynamic partition overwrite rejects unpartitioned tables upstream.
+// spec; unpartitioned dynamic overwrites do not need a row predicate.
// Void fields are also accepted when their source column has been dropped (the
// spec represents those tombstones with source ID 0), because void always
// produces a null partition value and does not need a source column.
diff --git a/website/src/api.md b/website/src/api.md
index 954a38358..9e351c05c 100644
--- a/website/src/api.md
+++ b/website/src/api.md
@@ -359,6 +359,41 @@ newTbl, err = tbl.Overwrite(ctx, recordReader, nil,
`OverwriteTable` is the `arrow.Table` variant.
+### Dynamic partition overwrite
+
+Replace only the partitions present in the incoming data. Unlike a filtered
+row-level overwrite, this removes **all existing data files** in those
partitions
+while retaining files in other partitions.
+
+```go
+// Write Arrow record batches, replacing only partitions receiving new rows.
+newTbl, err := tbl.DynamicPartitionOverwrite(ctx, recordReader, nil)
+
+// In-memory Arrow tables work too.
+newTbl, err = tbl.DynamicPartitionOverwriteTable(ctx, arrowTbl, 1024, nil)
+
+// Or commit already-written Iceberg DataFile metadata through a transaction.
+txn := tbl.NewTransaction()
+err = txn.DynamicPartitionOverwriteDataFiles(ctx, newDataFiles, nil)
+if err == nil {
+ newTbl, err = txn.Commit(ctx)
+}
+```
+
+This supports Iceberg format v1-v3. Incoming files must use the current
+partition spec. On partitioned tables, files under older specs are preserved;
+only matching current-spec partitions are replaced. If the current spec is
+unpartitioned, non-empty input replaces all live files, including files under
+older specs. For v3, replacement also removes deletion vectors tied to
+replaced data files. Empty input leaves the table untouched.
+
+A concurrent add or removal in a replaced partition fails validation, while
+independent changes to other partitions can be retried after a catalog refresh.
+A new target branch also pins the source branch to prevent missing concurrent
+writes. Default partition-spec and sort-order changes fail validation. Written
+data files are not automatically cleaned up if staging or commit fails, so
+callers must handle those files before retrying with a new transaction.
+
### Delete
```go