Internal

Hi Xtable Team,

Hope this message finds you well. I tried to create an issue on github but I 
always run into issue. Hence, emailing below to get your attention.

Iceberg metadata generated by XTable records partition evolution during initial 
sync, causing Databricks Delta clone incompatibility

We are using Apache XTable to generate Iceberg metadata over existing Hudi 
tables registered in AWS Glue.
The generated Iceberg tables are readable by Iceberg-compatible engines, such 
as Athena (Trino) and AWS EMR(spark)  but Databricks fails when attempting to 
use the table through its Iceberg-to-Delta clone path with the following error:

[DELTA_CLONE_INCOMPATIBLE_SOURCE.ICEBERG_UNDERGONE_PARTITION_EVOLUTION]

The clone source has valid format, but has unsupported feature with Delta.
Source iceberg table has undergone partition evolution.

SQLSTATE: 0AKDC

After inspecting the generated Iceberg metadata, it appears that XTable creates 
the table initially as unpartitioned and then applies the actual source 
partition specification in a subsequent metadata commit.
This creates two Iceberg partition specifications:

spec-id 0: unpartitioned
spec-id 1: identity(df_meta_p_cppib)

Although no data files were written using spec 0, Databricks detects the 
presence of multiple partition specifications and classifies the table as 
having undergone partition evolution.
Environment
XTable artifact:

xtable-utilities_2.12-0.2.0-SNAPSHOT-bundled.jar

Source table format:

Apache Hudi
COPY_ON_WRITE
Parquet

Target format:

Apache Iceberg format version 2
AWS Glue catalog
S3FileIO

Target consumers include Databricks.
Source table partitioning
The Hudi source table is partitioned by:

df_meta_p_cppib

The partition definition was not intentionally changed after table creation.
The expected Iceberg partition specification is:

identity(df_meta_p_cppib)

XTable execution
XTable was run against the Hudi base path to generate an Iceberg target.
The generated Iceberg metadata directory contained:

00000-444ce851-9abd-4d4b-98ea-947f11a2f914.metadata.json
00001-87e5211c-cfb3-4b10-ae04-4fca25f0a5b2.metadata.json
00002-312c2a3b-a430-4f4b-803a-9262c71cf013.metadata.json
snap-5065718056232190869-1-2b42ae3a-edc2-4357-8eff-1fd32dfb6ddb.avro
2b42ae3a-edc2-4357-8eff-1fd32dfb6ddb-m0.avro

These files were created within approximately two seconds:

2026-07-20 13:41:12  00000-...metadata.json
2026-07-20 13:41:13  00001-...metadata.json
2026-07-20 13:41:14  00002-...metadata.json

The current Glue table points to:

metadata_location:
s3://cppib-data-fabric-trusted-qa/om/om_europe_country/metadata/00002-312c2a3b-a430-4f4b-803a-9262c71cf013.metadata.json

previous_metadata_location:
s3://cppib-data-fabric-trusted-qa/om/om_europe_country/metadata/00001-87e5211c-cfb3-4b10-ae04-4fca25f0a5b2.metadata.json

Current partition metadata
The current Iceberg metadata contains:

{
  "format_version": 2,
  "default_spec_id": 1,
  "last_partition_id": 1000,
  "partition_spec_count": 2,
  "partition_specs": [
    {
      "spec_id": 0,
      "fields": []
    },
    {
      "spec_id": 1,
      "fields": [
        {
          "name": "df_meta_p_cppib",
          "transform": "identity",
          "source-id": 14,
          "field-id": 1000
        }
      ]
    }
  ]
}

The raw Iceberg representation is effectively:

{
  "partition-specs": [
    {
      "spec-id": 0,
      "fields": []
    },
    {
      "spec-id": 1,
      "fields": [
        {
          "name": "df_meta_p_cppib",
          "transform": "identity",
          "source-id": 14,
          "field-id": 1000
        }
      ]
    }
  ],
  "default-spec-id": 1
}

The manifest metadata confirms that all actual data files use only partition 
spec 1:

partition_spec_id = 1

Example manifest output:

path:
s3://cppib-data-fabric-trusted-qa/om/om_europe_country/metadata/2b42ae3a-edc2-4357-8eff-1fd32dfb6ddb-m0.avro

partition_spec_id: 1
added_data_files_count: 1
added_rows_count: 260

No data files appear to have been written under partition spec 0.
Metadata history
The latest metadata file contains the following metadata log:

{
  "metadata_log": [
    {
      "timestamp-ms": 1784554871636,
      "metadata-file": 
"s3://cppib-data-fabric-trusted-qa/om/om_europe_country/metadata/00000-444ce851-9abd-4d4b-98ea-947f11a2f914.metadata.json"
    },
    {
      "timestamp-ms": 1784554872506,
      "metadata-file": 
"s3://cppib-data-fabric-trusted-qa/om/om_europe_country/metadata/00001-87e5211c-cfb3-4b10-ae04-4fca25f0a5b2.metadata.json"
    }
  ],
  "snapshots": [
    {
      "snapshot_id": 5065718056232190869,
      "timestamp_ms": 1784554873567,
      "operation": "append"
    }
  ]
}

There is only one data snapshot, and its operation is append.
A comparison of the partition metadata in 00001 and 00002 shows no difference:

diff -u \
  <(jq -S '{
      default_spec_id: ."default-spec-id",
      last_partition_id: ."last-partition-id",
      partition_specs: ."partition-specs"
    }' 00001.metadata.json) \
  <(jq -S '{
      default_spec_id: ."default-spec-id",
      last_partition_id: ."last-partition-id",
      partition_specs: ."partition-specs"
    }' 00002.metadata.json)

The command produces no output.
This suggests that the partition evolution occurred between metadata versions 
00000 and 00001, before the data snapshot was added.
Suspected XTable behavior
The observed metadata is consistent with the following initialization sequence:

1. Create a new Iceberg table with an empty schema and an unpartitioned spec.
2. Synchronize the Hudi schema.
3. Apply identity(df_meta_p_cppib) as a new partition specification.
4. Add the Hudi data files under the new spec.

The result is:

metadata 00000:
  spec 0 = unpartitioned

metadata 00001:
  spec 0 = unpartitioned
  spec 1 = identity(df_meta_p_cppib)

metadata 00002:
  append data files under spec 1

>From a business and data perspective, the partitioning did not evolve. The 
>intended partition definition was always:

identity(df_meta_p_cppib)

However, from the Iceberg metadata perspective, the table evolved from 
unpartitioned to partitioned.
Impact
Databricks rejects the table because it sees multiple partition specifications:

[DELTA_CLONE_INCOMPATIBLE_SOURCE.ICEBERG_UNDERGONE_PARTITION_EVOLUTION]

This occurs even though:

  *   There is only one data snapshot.
  *   All data files use the same partition specification.
  *   No data files were written while the table was unpartitioned.
  *   The source Hudi table’s partitioning was not changed.
  *   The extra unpartitioned spec appears to be an initialization artifact.

This makes XTable-generated Iceberg metadata incompatible with Databricks 
workflows that require the source Iceberg table not to have undergone partition 
evolution.
Expected behavior
For a newly generated Iceberg target, XTable should create the table with the 
final source schema and partition specification in the initial Iceberg metadata 
commit.
The expected initial metadata should contain:

{
  "default-spec-id": 0,
  "partition-specs": [
    {
      "spec-id": 0,
      "fields": [
        {
          "name": "df_meta_p_cppib",
          "transform": "identity",
          "source-id": 14,
          "field-id": 1000
        }
      ]
    }
  ]
}

The expected result is:

spec count = 1
default spec ID = 0
spec 0 = identity(df_meta_p_cppib)

All generated manifests should then use:

partition_spec_id = 0

Actual behavior
The generated table contains:

spec count = 2
default spec ID = 1
spec 0 = unpartitioned
spec 1 = identity(df_meta_p_cppib)

All data uses spec 1, but the unused spec 0 remains in table metadata.
Requested guidance
Could the XTable maintainers clarify:
1. Is creating an initially unpartitioned Iceberg table an intentional part of 
XTable’s table-creation flow?
2. Is this done to preserve source schema field IDs from Hudi or the existing 
Parquet files?
3. Can the final Hudi-derived schema and partition specification be supplied 
during the initial Iceberg table creation so the intended partition becomes 
spec 0?
4. Is there an existing configuration or creation mode that avoids the 
empty-schema and unpartitioned-spec initialization?
5. Would the project accept a change that introduces a Databricks-compatible 
creation mode, for example:

Create the Iceberg table with the final schema and final partition spec
in the first metadata commit.

6. If preserving source field IDs requires the current initialization approach, 
is there another supported way to preserve IDs without first creating an 
unpartitioned spec?
7. Is this behavior already addressed in a newer XTable version or branch?
Possible fix
A possible implementation would be to create a new Iceberg table using the 
complete schema and partition spec together:

catalog.createTable(
    tableIdentifier,
    finalSchema,
    finalPartitionSpec,
    basePath,
    tableProperties
);

Instead of first creating:

catalog.createTable(
    tableIdentifier,
    new Schema(),
    PartitionSpec.unpartitioned(),
    basePath,
    tableProperties
);

and subsequently applying the actual schema and partition specification.
The fix would need to account for XTable’s schema field-ID preservation 
requirements.
A useful validation test would assert that, after the initial synchronization 
of a partitioned Hudi source:

assertThat(table.specs()).hasSize(1);
assertThat(table.spec().specId()).isEqualTo(0);
assertThat(table.spec().fields()).hasSize(1);
assertThat(table.spec().fields().get(0).name())
    .isEqualTo("df_meta_p_cppib");

A second identical sync should also leave the table with only one partition 
spec.
Reproduction outline
1. Create a partitioned Hudi table with partition column:

df_meta_p_cppib

2. Run XTable to generate an Iceberg target in AWS Glue.
3. Download the generated Iceberg metadata JSON.
4. Inspect the partition specifications:

jq '{
  default_spec_id: ."default-spec-id",
  partition_spec_count: (."partition-specs" | length),
  partition_specs: ."partition-specs"
}' current.metadata.json

5. Observe:

spec 0 = unpartitioned
spec 1 = identity(df_meta_p_cppib)

6. Query the manifest metadata and observe that only spec 1 is used:

SELECT
    partition_spec_id,
    COUNT(*) AS manifest_count
FROM "<database>"."<table>$manifests"
GROUP BY partition_spec_id;

7. Attempt the relevant operation from Databricks.
8. Observe:

DELTA_CLONE_INCOMPATIBLE_SOURCE.ICEBERG_UNDERGONE_PARTITION_EVOLUTION

Additional related observation
We also attempted to pre-create an Iceberg table so that the intended partition 
could be present as spec 0 before XTable synchronization.
The pre-created metadata included fields such as:

{
  "initial-default": null,
  "write-default": null
}

The Iceberg parser bundled with the XTable snapshot failed while loading that 
metadata:

java.lang.NullPointerException:
Cannot create expression literal from null

at org.apache.iceberg.expressions.Literals.from
at org.apache.iceberg.expressions.Expressions.lit
at org.apache.iceberg.SchemaParser.defaultFromJson

This appears to be a separate Iceberg-version compatibility issue, but it makes 
manually pre-creating the target difficult as a workaround.
The preferred solution would therefore be for XTable itself to create the 
initial Iceberg metadata using the final schema and partition specification.
Summary
XTable appears to record an unused unpartitioned spec during initial Iceberg 
target creation:

spec 0 = unpartitioned
spec 1 = intended partition

Databricks interprets this as partition evolution and rejects the table, even 
though all data files use only the intended partition specification.
It would be helpful to have either:

  *   an XTable creation mode that initializes the Iceberg target with the 
final partition spec as spec 0;
  *   a fix to the default table-creation sequence; or
  *   guidance on a supported workaround that preserves field IDs without 
generating partition evolution.

Thanks,
Karthik

Reply via email to