diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriver.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriver.java
new file mode 100644
index 000000000000..5da44a6c51a1
--- /dev/null
+++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriver.java
@@ -0,0 +1,438 @@
+/*
+ * 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 org.apache.beam.sdk.io.iceberg;
+
+import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.auto.value.AutoValue;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.beam.sdk.annotations.Internal;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.coders.MapCoder;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.coders.VoidCoder;
+import org.apache.beam.sdk.metrics.Counter;
+import org.apache.beam.sdk.metrics.Metrics;
+import org.apache.beam.sdk.state.MapState;
+import org.apache.beam.sdk.state.StateSpec;
+import org.apache.beam.sdk.state.StateSpecs;
+import org.apache.beam.sdk.transforms.Combine;
+import org.apache.beam.sdk.transforms.Deduplicate;
+import org.apache.beam.sdk.transforms.Distinct;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.DoFn.StateId;
+import org.apache.beam.sdk.transforms.PTransform;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.Reshuffle;
+import org.apache.beam.sdk.transforms.Sample;
+import org.apache.beam.sdk.transforms.View;
+import org.apache.beam.sdk.transforms.WithKeys;
+import org.apache.beam.sdk.transforms.display.DisplayData;
+import org.apache.beam.sdk.transforms.windowing.AfterPane;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.transforms.windowing.GlobalWindows;
+import org.apache.beam.sdk.transforms.windowing.PaneInfo;
+import org.apache.beam.sdk.transforms.windowing.Repeatedly;
+import org.apache.beam.sdk.transforms.windowing.Window;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionView;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.ValueInSingleWindow;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.Duration;
+import org.joda.time.Instant;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A driver transform that extracts table identifiers from incoming {@link Row}s, deduplicates them
+ * per window, optionally bounds the cache size up to {@code maximumCacheSize}, loads their
+ * declarative metadata from the Iceberg catalog, and emits {@link KV} pairs of table identifier
+ * strings to {@link SerializableTableSpec}. This is intended to be used in Beam pipelines that may
+ * utilize a large number of workers to handle Iceberg writes, where having every worker thread
+ * query for table metadata results in an excessive amount of requests and a high level of
+ * redundancy.
+ *
+ *
Can also be materialized into a broadcasted {@link PCollectionView} via {@link
+ * #asView(IcebergCatalogConfig, DynamicDestinations)}. By default, the cache size is uncapped. If
+ * {@code maximumCacheSize} is configured and the number of distinct tables in a window exceeds it,
+ * up to {@code maximumCacheSize} tables are sampled into the broadcasted view, while remaining
+ * destinations fall back to worker-local catalog loading.
+ *
+ *
For unbounded streaming pipelines in {@link GlobalWindows}, {@link Deduplicate} is used to
+ * deduplicate table identifiers over the configured {@code refreshInterval} (defaulting to {@link
+ * #DEFAULT_REFRESH_INTERVAL}), allowing periodic refresh of table metadata when schemas evolve.
+ */
+@Internal
+@AutoValue
+public abstract class TableMetadataDriver
+ extends PTransform, PCollection>> {
+
+ public static final Duration DEFAULT_REFRESH_INTERVAL = Duration.standardMinutes(5);
+ public static final int DEFAULT_POLLING_BUCKETS = 1;
+
+ public abstract IcebergCatalogConfig getCatalogConfig();
+
+ public abstract DynamicDestinations getDynamicDestinations();
+
+ public abstract @Nullable Integer getMaximumCacheSize();
+
+ public abstract @Nullable Duration getRefreshInterval();
+
+ /**
+ * Returns the number of parallel buckets/workers used to query the Iceberg catalog, or {@code
+ * null} for default.
+ */
+ public abstract @Nullable Integer getPollingBuckets();
+
+ public static Builder builder() {
+ return new AutoValue_TableMetadataDriver.Builder();
+ }
+
+ public abstract Builder toBuilder();
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ public abstract Builder setCatalogConfig(IcebergCatalogConfig catalogConfig);
+
+ public abstract Builder setDynamicDestinations(DynamicDestinations dynamicDestinations);
+
+ public abstract Builder setMaximumCacheSize(@Nullable Integer maximumCacheSize);
+
+ public abstract Builder setRefreshInterval(@Nullable Duration refreshInterval);
+
+ /**
+ * Sets the number of parallel buckets (worker tasks) used to query the Iceberg catalog.
+ *
+ * Defaults to {@link #DEFAULT_POLLING_BUCKETS} (1), which serializes all catalog lookups to
+ * avoid overwhelming catalog metastores (e.g. Hive Metastore, REST catalog). For pipelines
+ * writing to a large number of distinct dynamic tables (e.g. hundreds of tables per window),
+ * consider increasing this value (e.g. 5–10) to parallelize catalog lookups while still
+ * bounding load.
+ */
+ public abstract Builder setPollingBuckets(@Nullable Integer pollingBuckets);
+
+ abstract TableMetadataDriver autoBuild();
+
+ public TableMetadataDriver build() {
+ TableMetadataDriver driver = autoBuild();
+ Integer maxCacheSize = driver.getMaximumCacheSize();
+ if (maxCacheSize != null) {
+ Preconditions.checkArgument(
+ maxCacheSize > 0, "maximumCacheSize must be greater than 0, got %s", maxCacheSize);
+ }
+ Duration refreshInterval = driver.getRefreshInterval();
+ if (refreshInterval != null) {
+ Preconditions.checkArgument(
+ refreshInterval.isLongerThan(Duration.ZERO),
+ "refreshInterval must be positive, got %s",
+ refreshInterval);
+ }
+ Integer pollingBuckets = driver.getPollingBuckets();
+ if (pollingBuckets != null) {
+ Preconditions.checkArgument(
+ pollingBuckets > 0, "pollingBuckets must be greater than 0, got %s", pollingBuckets);
+ }
+ return driver;
+ }
+ }
+
+ /**
+ * Helper that applies {@link TableMetadataDriver} and creates an uncapped {@link PCollectionView}
+ * of {@link Map} of table identifier strings to {@link SerializableTableSpec}.
+ */
+ public static PTransform, PCollectionView