This is an automated email from the ASF dual-hosted git repository.

yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 8e80829c86 [Cherry-pick to branch-1.3] [#14484] fix(jdbc): validate 
configuration and classify PostgreSQL failures (#14485) (#14553)
8e80829c86 is described below

commit 8e80829c86feb7bd024f5185f05323014261c665
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Nov 3 17:24:37 2026 +0800

    [Cherry-pick to branch-1.3] [#14484] fix(jdbc): validate configuration and 
classify PostgreSQL failures (#14485) (#14553)
    
    **Cherry-pick Information:**
    - Original commit: c7a4551b092e2f2dc2cbee99ca76735e2d8d770f (#14485)
    - Target branch: `branch-1.3`
    - Status: Conflicts resolved.
    
    Backports static JDBC configuration validation and PostgreSQL failure
    classification. The catalog alter hook is adapted to the existing 1.3
    lifecycle and persistence flow; main-only secret-management APIs and
    their tests are excluded. Includes the native pool-property guard
    required to keep static validation connection-free, plus missing test
    dependencies and fixtures.
    
    Validation: Spotless and `git diff --check` passed. JDBC common: 103
    tests; PostgreSQL: 266 tests (including PostgreSQL 12–16 Docker
    integration suites); filtered `TestCatalogManager`: 39 tests;
    `TestCatalogWrapperLease`: 18 tests. All passed with no skips. The two
    manager configuration-validation regression tests also failed as
    expected with the validation calls removed, then passed after
    restoration.
    
    Fresh-context review of f08ff43933 found no blocking or important
    issues.
    
    ---------
    
    Co-authored-by: Qi Yu <[email protected]>
---
 .../apache/gravitino/catalog/jdbc/JdbcCatalog.java |  32 +++-
 .../catalog/jdbc/JdbcCatalogOperations.java        |  25 +--
 .../jdbc/JdbcCatalogPropertiesMetadata.java        |  19 +-
 .../gravitino/catalog/jdbc/config/JdbcConfig.java  |  14 +-
 .../catalog/jdbc/utils/DataSourceUtils.java        | 199 +++++++++++++++------
 .../jdbc/TestJdbcCatalogPropertiesMetadata.java    | 104 ++++++++++-
 .../jdbc/utils/TestDataSourceUrlValidation.java    |   4 +-
 .../jdbc/utils/TestDataSourceValidation.java       |  88 +++++++++
 catalogs/catalog-jdbc-postgresql/build.gradle.kts  |   2 +
 .../postgresql/PostgreSQLCatalogOperations.java    |   3 +-
 .../catalog/postgresql/PostgreSqlCatalog.java      |   4 +-
 .../PostgreSqlColumnDefaultValueConverter.java     |  12 ++
 .../converter/PostgreSqlExceptionConverter.java    |  21 ++-
 .../operation/PostgreSqlTableOperations.java       |  85 ++++++++-
 .../TestPostgreSqlCatalogConfiguration.java        |  28 +++
 .../TestPostgreSqlColumnDefaultValueConverter.java |  14 ++
 .../TestPostgreSqlExceptionConverter.java          |  79 ++++++++
 .../TestPostgreSqlTableCreatePrivileges.java       | 165 +++++++++++++++++
 .../operation/TestPostgreSqlTableOperations.java   | 151 ++++++++++++++++
 ...TestPostgreSqlTableOperationsSqlGeneration.java | 105 +++++++++++
 .../apache/gravitino/catalog/CatalogManager.java   |  50 ++++--
 .../apache/gravitino/connector/BaseCatalog.java    |  23 +++
 .../java/org/apache/gravitino/TestCatalog.java     |  10 ++
 .../gravitino/catalog/TestCatalogManager.java      |  69 +++++++
 .../gravitino/connector/TestCatalogOperations.java |   9 +-
 docs/jdbc-catalog-connection-validation.md         |   7 +-
 26 files changed, 1204 insertions(+), 118 deletions(-)

diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
index 7f5c2f831c..b4c8593397 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
@@ -31,6 +31,7 @@ import 
org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
 import org.apache.gravitino.catalog.jdbc.converter.JdbcTypeConverter;
 import org.apache.gravitino.catalog.jdbc.operation.JdbcDatabaseOperations;
 import org.apache.gravitino.catalog.jdbc.operation.JdbcTableOperations;
+import org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
 import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.connector.CatalogOperations;
 import org.apache.gravitino.connector.PropertiesMetadata;
@@ -86,6 +87,32 @@ public abstract class JdbcCatalog extends 
BaseCatalog<JdbcCatalog> {
     return false;
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public JdbcCatalog withCatalogConf(Map<String, String> configuration) {
+    return super.withCatalogConf(prepareConfiguration(configuration));
+  }
+
+  /**
+   * Normalizes and validates provider-specific settings for initialization 
and proposed changes.
+   *
+   * @param configuration the complete proposed configuration, which must not 
be mutated
+   * @return the normalized settings; the default returns the original map
+   * @throws IllegalArgumentException if provider-specific settings are 
inconsistent
+   */
+  protected Map<String, String> prepareConfiguration(Map<String, String> 
configuration) {
+    return configuration;
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void validateConfiguration(Map<String, String> configuration) {
+    JdbcCatalogPropertiesMetadata metadata =
+        (JdbcCatalogPropertiesMetadata) catalogPropertiesMetadata();
+    DataSourceUtils.validateConfiguration(
+        new 
JdbcConfig(metadata.jdbcProperties(prepareConfiguration(configuration))));
+  }
+
   /**
    * Returns the effective {@link JdbcConfig#VERIFY_ON_CREATE 
verify-on-create} setting for this
    * catalog. A relational JDBC catalog verifies its backend at create time by 
default; an
@@ -100,7 +127,10 @@ public abstract class JdbcCatalog extends 
BaseCatalog<JdbcCatalog> {
     if (properties == null) {
       return true;
     }
-    return new JdbcConfig(properties).getVerifyOnCreate();
+    JdbcCatalogPropertiesMetadata metadata =
+        (JdbcCatalogPropertiesMetadata) catalogPropertiesMetadata();
+    return new 
JdbcConfig(metadata.jdbcProperties(prepareConfiguration(properties)))
+        .getVerifyOnCreate();
   }
 
   /**
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
index 1286a35217..c5f067cc3c 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
@@ -18,8 +18,6 @@
  */
 package org.apache.gravitino.catalog.jdbc;
 
-import static org.apache.gravitino.connector.BaseCatalog.CATALOG_BYPASS_PREFIX;
-
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.Maps;
@@ -81,7 +79,6 @@ import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.utils.IsolatedClassLoader;
-import org.apache.gravitino.utils.MapUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -167,14 +164,7 @@ public class JdbcCatalogOperations implements 
CatalogOperations, SupportsSchemas
     this.jdbcTablePropertiesMetadata =
         (JdbcTablePropertiesMetadata) 
propertiesMetadata.tablePropertiesMetadata();
 
-    // Key format like gravitino.bypass.a.b
-    Map<String, String> prefixMap = MapUtils.getPrefixMap(conf, 
CATALOG_BYPASS_PREFIX);
-
-    // Hold keys that lie in JDBC_IMMUTABLE_PROPERTIES
-    Map<String, String> gravitinoConfig =
-        this.jdbcCatalogPropertiesMetadata.transformProperties(conf);
-    Map<String, String> resultConf = Maps.newHashMap(prefixMap);
-    resultConf.putAll(gravitinoConfig);
+    Map<String, String> resultConf = 
this.jdbcCatalogPropertiesMetadata.jdbcProperties(conf);
 
     this.jdbcConfig = new JdbcConfig(resultConf);
     this.jdbcUrl = jdbcConfig.getJdbcUrl();
@@ -228,18 +218,15 @@ public class JdbcCatalogOperations implements 
CatalogOperations, SupportsSchemas
       return null;
     }
 
-    return DriverManager.getDriver(jdbcUrl);
+    return DataSourceUtils.configuredDriver(jdbcConfig);
   }
 
   /**
-   * Initializes the configured driver class so that it registers itself with 
{@link DriverManager}
-   * before {@link #getDriver()} looks it up.
+   * Initializes the configured driver for provider overrides that still use 
{@link DriverManager}.
    *
-   * <p>{@code DriverManager} only discovers drivers on the server classpath 
by itself. A driver in
-   * the catalog's own libs directory registers only when its class is 
initialized, which the lazy
-   * connection pool does on its first connection. The create-time probe runs 
before that, so
-   * without this the lookup fails with "No suitable driver". The class is 
loaded by this catalog's
-   * class loader, which is also the loader {@code DriverManager} checks the 
caller against.
+   * <p>The default {@link #getDriver()} instantiates the configured driver 
directly. Legacy
+   * overrides, such as {@link 
MySQLProtocolCompatibleCatalogOperations#getDriver()}, instead use
+   * DriverManager lookup, which requires drivers in catalog-specific libs to 
register first.
    */
   private void ensureDriverRegistered() {
     String driverClassName = jdbcConfig.getJdbcDriver();
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
index 5dc879452e..3bad98923d 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.catalog.jdbc;
 
+import static org.apache.gravitino.connector.BaseCatalog.CATALOG_BYPASS_PREFIX;
 import static 
org.apache.gravitino.connector.PropertyEntry.booleanPropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.integerPropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.longOptionalPropertyEntry;
@@ -32,6 +33,7 @@ import java.util.Map;
 import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
 import org.apache.gravitino.connector.BaseCatalogPropertiesMetadata;
 import org.apache.gravitino.connector.PropertyEntry;
+import org.apache.gravitino.utils.MapUtils;
 
 public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata {
 
@@ -120,7 +122,7 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
                 false /* required */,
                 false /* immutable */,
                 JdbcConfig.TEST_ON_BORROW.getDefaultValue(),
-                true /* hidden */,
+                false /* hidden */,
                 false /* reserved */),
             longOptionalPropertyEntry(
                 JdbcConfig.CONNECT_TIMEOUT_MS.getKey(),
@@ -134,7 +136,7 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
                 false /* required */,
                 false /* immutable */,
                 JdbcConfig.VERIFY_ON_CREATE.getDefaultValue(),
-                true /* hidden */,
+                false /* hidden */,
                 false /* reserved */));
     PROPERTIES_METADATA =
         ImmutableMap.<String, PropertyEntry<?>>builder()
@@ -147,6 +149,19 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
     return PROPERTIES_METADATA;
   }
 
+  /**
+   * Returns the effective JDBC settings used by validation and operation 
initialization.
+   *
+   * @param properties the catalog configuration
+   * @return a new map with bypass prefixes removed and canonical JDBC 
properties taking precedence
+   */
+  public Map<String, String> jdbcProperties(Map<String, String> properties) {
+    Map<String, String> result =
+        Maps.newHashMap(MapUtils.getPrefixMap(properties, 
CATALOG_BYPASS_PREFIX));
+    result.putAll(transformProperties(properties));
+    return result;
+  }
+
   public Map<String, String> transformProperties(Map<String, String> 
properties) {
     Map<String, String> result = Maps.newHashMap();
     properties.forEach(
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
index a6ff2aa7e9..435fe832eb 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
@@ -177,7 +177,7 @@ public class JdbcConfig extends Config {
   }
 
   public boolean getTestOnBorrow() {
-    return get(TEST_ON_BORROW);
+    return strictBoolean(TEST_ON_BORROW);
   }
 
   public long getMaxWaitMs() {
@@ -199,11 +199,21 @@ public class JdbcConfig extends Config {
    * @return {@code true} to verify the backend at create (default); {@code 
false} to skip the check
    */
   public boolean getVerifyOnCreate() {
-    return get(VERIFY_ON_CREATE);
+    return strictBoolean(VERIFY_ON_CREATE);
   }
 
   public JdbcConfig(Map<String, String> properties) {
     super(false);
     loadFromMap(properties, k -> true);
   }
+
+  private boolean strictBoolean(ConfigEntry<Boolean> entry) {
+    String value = getAllConfig().get(entry.getKey());
+    if (value != null
+        && !"true".equalsIgnoreCase(value.trim())
+        && !"false".equalsIgnoreCase(value.trim())) {
+      throw new IllegalArgumentException(entry.getKey() + " must be true or 
false");
+    }
+    return get(entry);
+  }
 }
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
index c298fbd4d1..1d3e5d03c8 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
@@ -18,8 +18,10 @@
  */
 package org.apache.gravitino.catalog.jdbc.utils;
 
+import java.sql.Driver;
 import java.sql.SQLException;
 import java.time.Duration;
+import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import javax.sql.DataSource;
@@ -36,6 +38,32 @@ import org.apache.gravitino.utils.JdbcUrlUtils;
  */
 public class DataSourceUtils {
 
+  // DBCP2 connection-pool properties that must never come from catalog 
configuration. The whole
+  // config map is handed to BasicDataSourceFactory, so allowing these would 
either run arbitrary
+  // code or let a raw property override the validated canonical connection 
fields:
+  //   - connectionFactoryClassName / evictionPolicyClassName / 
driverClassName: their values are
+  //     class names the factory loads and instantiates via reflection 
(Class.forName + newInstance)
+  //     when the pool creates connections (remote code execution). The 
legitimate driver is set
+  //     separately from the "jdbc-driver" property via an explicit setter, so 
the raw
+  //     "driverClassName" key is never needed.
+  //   - url / username / password: the connection identity is validated 
(JdbcUrlUtils, the H2
+  //     guard) and applied from the canonical 
"jdbc-url"/"jdbc-user"/"jdbc-password" properties via
+  //     explicit setters. A raw DBCP key here would reach the factory 
unvalidated and, once the
+  //     pool initializes, take effect before those setters run — e.g. a raw 
"url" could smuggle an
+  //     H2 or otherwise-denied URL past the guards.
+  //   - initialSize: defense in depth. A value > 0 makes the factory eagerly 
open a connection
+  //     during createDataSource; blocking it keeps pool creation lazy so our 
explicit url/driver
+  //     setters, not factory-time eager init, control how connections are 
created.
+  private static final List<String> UNSAFE_POOL_PROPERTIES =
+      List.of(
+          "connectionFactoryClassName",
+          "evictionPolicyClassName",
+          "driverClassName",
+          "url",
+          "username",
+          "password",
+          "initialSize");
+
   /** SQL statements for database connection pool testing. */
   private static final String POOL_TEST_QUERY = "SELECT 1";
 
@@ -45,6 +73,22 @@ public class DataSourceUtils {
 
   public static DataSource createDataSource(JdbcConfig jdbcConfig)
       throws GravitinoRuntimeException {
+    validateConfiguration(jdbcConfig);
+    try {
+      return createDBCPDataSource(jdbcConfig);
+    } catch (Exception exception) {
+      throw new GravitinoRuntimeException(exception, "Error creating 
datasource");
+    }
+  }
+
+  /**
+   * Validates JDBC settings without opening a connection or retaining a pool.
+   *
+   * @param jdbcConfig the effective JDBC configuration
+   * @throws IllegalArgumentException if a boolean, pool bound, driver or URL 
is invalid
+   * @throws GravitinoRuntimeException if a dangerous URL or driver setting is 
rejected
+   */
+  public static void validateConfiguration(JdbcConfig jdbcConfig) {
     // H2 is bundled as an embedded backend and must not be used through 
user-facing catalog
     // configuration. Its INIT parameter allows arbitrary SQL (and Java code 
via CREATE ALIAS)
     // to execute at connection time, and the H2 driver class must also be 
blocked to prevent
@@ -56,61 +100,102 @@ public class DataSourceUtils {
     if (jdbcConfig.getJdbcDriver().toLowerCase().startsWith("org.h2.")) {
       throw new GravitinoRuntimeException("H2 JDBC driver is not allowed in 
catalog configuration");
     }
+    // Reject DBCP2 pool properties that load arbitrary classes via reflection 
before handing the
+    // config to the factory. Kept outside the try below so the specific 
reason surfaces directly
+    // instead of being wrapped as "Error creating datasource".
+    rejectUnsafePoolProperties(jdbcConfig.getAllConfig());
+    // Validate user configuration before the data-source builder wraps 
infrastructure failures.
+    jdbcConfig.getVerifyOnCreate();
+    jdbcConfig.getTestOnBorrow();
+    jdbcConfig.getConnectTimeoutMs();
+    jdbcConfig.getMaxWaitMs();
+    int minSize = jdbcConfig.getPoolMinSize();
+    int maxSize = jdbcConfig.getPoolMaxSize();
+    if (minSize > maxSize) {
+      throw new IllegalArgumentException("jdbc.pool.min-size must not exceed 
jdbc.pool.max-size");
+    }
     try {
-      return createDBCPDataSource(jdbcConfig);
-    } catch (Exception exception) {
-      if (isDriverClassMissing(exception)) {
-        // Some JDBC drivers are not packaged with Gravitino and must be 
installed by the user.
-        // Surface a clear, actionable message naming the driver instead of a 
raw
-        // ClassNotFoundException.
-        throw new GravitinoRuntimeException(
-            exception,
-            "JDBC driver class '%s' was not found on the catalog classpath. 
Install the driver "
-                + "JAR in the catalog's libs directory and recreate the 
catalog.",
-            jdbcConfig.getJdbcDriver());
+      JdbcUrlUtils.validateJdbcConfig(
+          jdbcConfig.getJdbcDriver(), jdbcConfig.getJdbcUrl(), 
jdbcConfig.getAllConfig());
+    } catch (RuntimeException e) {
+      throw new GravitinoRuntimeException(e, "Error creating datasource");
+    }
+    configuredDriver(jdbcConfig);
+    Properties properties = getProperties(jdbcConfig);
+    try {
+      validateNativePoolProperties(properties);
+    } catch (Exception invalidProperties) {
+      // Identify a malformed native alias without printing unrelated settings 
or credentials.
+      for (String key : properties.stringPropertyNames()) {
+        Properties singleProperty = new Properties();
+        singleProperty.setProperty(key, properties.getProperty(key));
+        try {
+          validateNativePoolProperties(singleProperty);
+        } catch (Exception invalidProperty) {
+          throw new IllegalArgumentException(
+              "Invalid native JDBC connection-pool property '"
+                  + key
+                  + "': "
+                  + invalidProperty.getMessage(),
+              invalidProperty);
+        }
       }
-      throw new GravitinoRuntimeException(exception, "Error creating 
datasource");
+      throw new IllegalArgumentException(
+          "Invalid native JDBC connection-pool properties; check 
gravitino.bypass.* settings",
+          invalidProperties);
+    }
+  }
+
+  private static void validateNativePoolProperties(Properties properties) 
throws Exception {
+    // initialSize and class-loading properties were rejected before this 
method. The factory
+    // therefore only parses an uninitialized configuration bean; no 
connections are opened.
+    try (BasicDataSource ignored = 
BasicDataSourceFactory.createDataSource(properties)) {
+      // Always close the temporary bean, including on the diagnostic path for 
individual keys.
     }
   }
 
   /**
-   * Returns whether the given throwable chain indicates a missing JDBC driver 
class. DBCP2 reports
-   * an absent driver as a {@link ClassNotFoundException} (sometimes wrapped 
in a {@link
-   * SQLException} whose message is "Cannot load JDBC driver class ..."), so 
both the exception
-   * chain and that message are checked.
+   * Creates the configured driver and verifies that it accepts the configured 
JDBC URL.
    *
-   * @param throwable the throwable thrown while creating the data source
-   * @return {@code true} if the failure is due to a driver class that cannot 
be loaded
+   * @param config the JDBC catalog configuration
+   * @return the configured JDBC driver
+   * @throws IllegalArgumentException if the driver is absent, invalid, or 
incompatible with the URL
    */
-  private static boolean isDriverClassMissing(Throwable throwable) {
-    for (Throwable current = throwable; current != null; current = 
current.getCause()) {
-      if (current instanceof ClassNotFoundException || current instanceof 
NoClassDefFoundError) {
-        return true;
-      }
-      String message = current.getMessage();
-      if (message != null && message.contains("Cannot load JDBC driver 
class")) {
-        return true;
+  public static Driver configuredDriver(JdbcConfig config) {
+    String name = config.getJdbcDriver();
+    ClassLoader loader = Thread.currentThread().getContextClassLoader();
+    if (loader == null) {
+      loader = DataSourceUtils.class.getClassLoader();
+    }
+    try {
+      Class<?> type = Class.forName(name, false, loader);
+      if (!Driver.class.isAssignableFrom(type)) {
+        throw new IllegalArgumentException(
+            "jdbc-driver '" + name + "' must implement java.sql.Driver");
       }
-      if (current.getCause() == current) {
-        break;
+      Driver driver = 
type.asSubclass(Driver.class).getDeclaredConstructor().newInstance();
+      if (!driver.acceptsURL(config.getJdbcUrl())) {
+        throw new IllegalArgumentException("jdbc-driver '" + name + "' does 
not accept jdbc-url");
       }
+      return driver;
+    } catch (ClassNotFoundException e) {
+      throw new IllegalArgumentException(
+          "JDBC driver class '"
+              + name
+              + "' was not found on the catalog classpath. Install the driver 
JAR in the catalog's libs directory.",
+          e);
+    } catch (ReflectiveOperationException | SQLException | LinkageError e) {
+      throw new IllegalArgumentException(
+          "Invalid jdbc-driver '" + name + "': driver must be installed and 
usable", e);
     }
-    return false;
   }
 
   private static DataSource createDBCPDataSource(JdbcConfig jdbcConfig) throws 
Exception {
-    JdbcUrlUtils.validateJdbcConfig(
-        jdbcConfig.getJdbcDriver(), jdbcConfig.getJdbcUrl(), 
jdbcConfig.getAllConfig());
     BasicDataSource basicDataSource =
         BasicDataSourceFactory.createDataSource(getProperties(jdbcConfig));
     String jdbcUrl = jdbcConfig.getJdbcUrl();
     basicDataSource.setUrl(jdbcUrl);
     String driverClassName = jdbcConfig.getJdbcDriver();
-    // DBCP2 loads the driver lazily on the first connection, so a missing 
driver would otherwise
-    // only surface much later (and as an opaque error). Verify the driver 
class is on the catalog
-    // classpath now, so catalog creation fails fast with a clear, actionable 
message. Loaded
-    // without initialization; the H2 driver is already rejected above.
-    verifyDriverPresent(driverClassName);
     basicDataSource.setDriverClassName(driverClassName);
     String userName = jdbcConfig.getUsername();
     basicDataSource.setUsername(userName);
@@ -137,23 +222,6 @@ public class DataSourceUtils {
     return basicDataSource;
   }
 
-  /**
-   * Verifies that the configured JDBC driver class is loadable from the 
catalog classpath. The
-   * class is resolved without initialization using the catalog's context 
class loader (falling back
-   * to this class's loader), so an absent driver fails catalog creation 
immediately rather than on
-   * the first connection.
-   *
-   * @param driverClassName the fully qualified JDBC driver class name
-   * @throws ClassNotFoundException if the driver class is not on the catalog 
classpath
-   */
-  private static void verifyDriverPresent(String driverClassName) throws 
ClassNotFoundException {
-    ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-    if (classLoader == null) {
-      classLoader = DataSourceUtils.class.getClassLoader();
-    }
-    Class.forName(driverClassName, false, classLoader);
-  }
-
   private static Properties getProperties(JdbcConfig jdbcConfig) {
     Properties properties = new Properties();
     properties.putAll(jdbcConfig.getAllConfig());
@@ -177,6 +245,31 @@ public class DataSourceUtils {
     return decoded;
   }
 
+  /**
+   * Rejects DBCP2 connection-pool properties that would let catalog 
configuration run arbitrary
+   * classes on the server ({@code connectionFactoryClassName}, {@code 
evictionPolicyClassName},
+   * {@code driverClassName}) or override the validated canonical connection 
identity ({@code url},
+   * {@code username}, {@code password}), plus {@code initialSize} (blocked as 
defense in depth to
+   * keep pool creation lazy).
+   *
+   * @param config the JDBC configuration properties forwarded to the DBCP2 
factory
+   * @throws GravitinoRuntimeException if an unsafe connection-pool property 
is present
+   */
+  private static void rejectUnsafePoolProperties(Map<String, String> config) {
+    if (config == null) {
+      return;
+    }
+    for (String key : config.keySet()) {
+      for (String unsafe : UNSAFE_POOL_PROPERTIES) {
+        if (unsafe.equalsIgnoreCase(key)) {
+          throw new GravitinoRuntimeException(
+              "Unsafe JDBC connection pool property '%s' is not allowed in 
catalog configuration",
+              key);
+        }
+      }
+    }
+  }
+
   public static void closeDataSource(DataSource dataSource) {
     if (null != dataSource) {
       try {
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
index a77a6be771..235bcb5ff4 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
@@ -19,9 +19,13 @@
 package org.apache.gravitino.catalog.jdbc;
 
 import com.google.common.collect.ImmutableMap;
+import java.sql.Connection;
+import java.sql.SQLException;
 import java.time.Instant;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Properties;
 import org.apache.commons.dbcp2.BasicDataSource;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Catalog;
@@ -45,9 +49,98 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.sqlite.JDBC;
 
 public class TestJdbcCatalogPropertiesMetadata {
 
+  @Test
+  void testConfigurationValidationDoesNotDependOnBackendVerification() {
+    for (Map<String, String> invalid :
+        List.of(
+            Map.of("jdbc-driver", "java.lang.String"),
+            Map.of("jdbc-driver", "missing.Driver"),
+            Map.of("jdbc-url", "jdbc:postgresql://localhost/unused"),
+            Map.of("jdbc.pool.test-on-borrow", "maybe"),
+            Map.of("verify-on-create", "yes"),
+            Map.of("jdbc.pool.min-size", "0"),
+            Map.of("jdbc.pool.max-size", "-1"),
+            Map.of("jdbc.pool.min-size", "5", "jdbc.pool.max-size", "2"),
+            Map.of("jdbc.pool.connect-timeout-ms", "2147483648"))) {
+      Map<String, String> properties = poolWaitProperties();
+      properties.put("verify-on-create", "false");
+      properties.putAll(invalid);
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () -> new TestJdbcCatalog().validateConfiguration(properties),
+          invalid.toString());
+    }
+    Map<String, String> unavailable = poolWaitProperties();
+    unavailable.put("jdbc-driver", NoConnectionDriver.class.getName());
+    unavailable.put("jdbc-url", "jdbc:sqlite:unavailable");
+    unavailable.put("verify-on-create", "false");
+    // A static settings check must not attempt a connection to this 
unavailable backend.
+    Assertions.assertDoesNotThrow(() -> new 
TestJdbcCatalog().validateConfiguration(unavailable));
+  }
+
+  @Test
+  void testValidationUsesTheSameBypassPrecedenceAsOperations() {
+    Map<String, String> properties = poolWaitProperties();
+    properties.put("verify-on-create", "false");
+    properties.put("gravitino.bypass.jdbc.pool.min-size", "5");
+    properties.put("jdbc.pool.max-size", "2");
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new TestJdbcCatalog().validateConfiguration(properties));
+    properties.put("jdbc.pool.min-size", "1");
+    // Canonical JDBC settings override prefixed settings.
+    Assertions.assertDoesNotThrow(() -> new 
TestJdbcCatalog().validateConfiguration(properties));
+    properties.put("url", "ignored property");
+    Assertions.assertDoesNotThrow(() -> new 
TestJdbcCatalog().validateConfiguration(properties));
+    Assertions.assertFalse(
+        buildCatalog(Map.of("gravitino.bypass.verify-on-create", "false"))
+            .shouldValidateOnCreate());
+    Assertions.assertTrue(
+        buildCatalog(
+                Map.of("verify-on-create", "true", 
"gravitino.bypass.verify-on-create", "false"))
+            .shouldValidateOnCreate());
+    properties.put("gravitino.bypass.initialSize", "1");
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class,
+        () -> new TestJdbcCatalog().validateConfiguration(properties));
+  }
+
+  @Test
+  void testNativePoolPropertySyntaxIsValidatedWithoutBackendVerification() {
+    for (String key : List.of("maxWaitMillis", "maxIdle")) {
+      Map<String, String> properties = poolWaitProperties();
+      properties.put("verify-on-create", "false");
+      properties.put("gravitino.bypass." + key, "not-a-number");
+      IllegalArgumentException error =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> new TestJdbcCatalog().validateConfiguration(properties));
+      Assertions.assertTrue(error.getMessage().contains(key));
+    }
+  }
+
+  @Test
+  void testBooleanConnectionSettingsAreVisibleAndStrict() {
+    JdbcCatalogPropertiesMetadata metadata = new 
JdbcCatalogPropertiesMetadata();
+    
Assertions.assertFalse(metadata.isHiddenProperty(JdbcConfig.VERIFY_ON_CREATE.getKey()));
+    
Assertions.assertFalse(metadata.isHiddenProperty(JdbcConfig.TEST_ON_BORROW.getKey()));
+    for (String value : new String[] {"yes", "1", "perhaps", ""}) {
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              
buildCatalog(ImmutableMap.of(JdbcConfig.VERIFY_ON_CREATE.getKey(), value))
+                  .shouldValidateOnCreate());
+    }
+    Assertions.assertTrue(
+        buildCatalog(ImmutableMap.of("verify-on-create", 
"TRUE")).shouldValidateOnCreate());
+    Assertions.assertFalse(
+        buildCatalog(ImmutableMap.of("verify-on-create", 
"FALSE")).shouldValidateOnCreate());
+  }
+
   @Test
   public void testPoolSizePropertiesAreNotHidden() {
     JdbcCatalogPropertiesMetadata propertiesMetadata = new 
JdbcCatalogPropertiesMetadata();
@@ -111,7 +204,7 @@ public class TestJdbcCatalogPropertiesMetadata {
     properties.put(JdbcConfig.POOL_MAX_WAIT_MS.getKey(), value);
     try (JdbcCatalogOperations operations = poolWaitOperations()) {
       Assertions.assertThrows(
-          GravitinoRuntimeException.class,
+          IllegalArgumentException.class,
           () ->
               operations.initialize(
                   properties, poolWaitCatalogInfo(properties), new 
TestJdbcCatalog()));
@@ -236,6 +329,15 @@ public class TestJdbcCatalogPropertiesMetadata {
             .isHiddenProperty(JdbcConfig.CONNECT_TIMEOUT_MS.getKey()));
   }
 
+  /** A driver that proves settings validation does not open a connection. */
+  public static class NoConnectionDriver extends JDBC {
+    /** {@inheritDoc} */
+    @Override
+    public Connection connect(String url, Properties properties) throws 
SQLException {
+      throw new AssertionError("Configuration validation must not open a 
connection");
+    }
+  }
+
   private static class TestJdbcCatalog extends JdbcCatalog {
     @Override
     public String shortName() {
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
index ae9548e987..2c9ff63a9b 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
@@ -203,9 +203,9 @@ public class TestDataSourceUrlValidation {
     properties.put(JdbcConfig.USERNAME.getKey(), "test");
     properties.put(JdbcConfig.PASSWORD.getKey(), "test");
 
-    GravitinoRuntimeException gre =
+    IllegalArgumentException gre =
         Assertions.assertThrows(
-            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+            IllegalArgumentException.class, () -> 
DataSourceUtils.createDataSource(properties));
     Assertions.assertTrue(
         gre.getMessage().contains(missingDriver),
         "message should name the missing driver: " + gre.getMessage());
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
new file mode 100644
index 0000000000..6ccec6c2f8
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
@@ -0,0 +1,88 @@
+/*
+ * 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.gravitino.catalog.jdbc.utils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.apache.commons.dbcp2.BasicDataSource;
+import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
+import org.apache.gravitino.exceptions.GravitinoRuntimeException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Tests JDBC configuration validation before any backend connection. */
+public class TestDataSourceValidation {
+
+  @Test
+  void testInvalidConfigurationFailsBeforeConnection() {
+    for (Map<String, String> invalid :
+        List.of(
+            Map.of("jdbc-driver", "java.lang.String"),
+            Map.of("jdbc-driver", "missing.Driver"),
+            Map.of("jdbc-url", "jdbc:postgresql://localhost/db"),
+            Map.of("verify-on-create", "yes"),
+            Map.of("jdbc.pool.test-on-borrow", "maybe"),
+            Map.of("jdbc.pool.min-size", "0"),
+            Map.of("jdbc.pool.max-size", "-1"),
+            Map.of("jdbc.pool.min-size", "5", "jdbc.pool.max-size", "2"),
+            Map.of("jdbc.pool.max-wait-ms", "0"),
+            Map.of("jdbc.pool.connect-timeout-ms", "2147483648"))) {
+      IllegalArgumentException exception =
+          Assertions.assertThrows(IllegalArgumentException.class, () -> 
createDataSource(invalid));
+      Assertions.assertTrue(
+          exception.getMessage().toLowerCase(Locale.ROOT).contains("jdbc")
+              || exception.getMessage().contains("verify-on-create"));
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(
+      strings = {
+        "connectionFactoryClassName",
+        "evictionPolicyClassName",
+        "driverClassName",
+        "url",
+        "username",
+        "password",
+        "initialSize",
+        "INITIALSIZE"
+      })
+  void testUnsafeNativePoolPropertiesAreRejected(String key) {
+    GravitinoRuntimeException error =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
createDataSource(Map.of(key, "1")));
+    Assertions.assertTrue(error.getMessage().contains(key));
+  }
+
+  private static BasicDataSource createDataSource(Map<String, String> 
overrides) {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(JdbcConfig.JDBC_DRIVER.getKey(), "org.sqlite.JDBC");
+    properties.put(JdbcConfig.JDBC_URL.getKey(), "jdbc:sqlite::memory:");
+    properties.put(JdbcConfig.USERNAME.getKey(), "test");
+    properties.put(JdbcConfig.PASSWORD.getKey(), "test");
+    properties.put(JdbcConfig.POOL_MAX_SIZE.getKey(), "1");
+    properties.put(JdbcConfig.POOL_MIN_SIZE.getKey(), "1");
+    properties.putAll(overrides);
+    return (BasicDataSource) DataSourceUtils.createDataSource(properties);
+  }
+}
diff --git a/catalogs/catalog-jdbc-postgresql/build.gradle.kts 
b/catalogs/catalog-jdbc-postgresql/build.gradle.kts
index b19ae32ac7..6dde4d3b4e 100644
--- a/catalogs/catalog-jdbc-postgresql/build.gradle.kts
+++ b/catalogs/catalog-jdbc-postgresql/build.gradle.kts
@@ -48,6 +48,8 @@ dependencies {
   testImplementation(project(":server"))
   testImplementation(project(":server-common"))
 
+  testImplementation(libs.commons.dbcp2)
+  testImplementation(libs.mockito.core)
   testImplementation(libs.awaitility)
   testImplementation(libs.junit.jupiter.api)
   testImplementation(libs.junit.jupiter.params)
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSQLCatalogOperations.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSQLCatalogOperations.java
index b04ac0dad0..3539039058 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSQLCatalogOperations.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSQLCatalogOperations.java
@@ -19,7 +19,6 @@
 package org.apache.gravitino.catalog.postgresql;
 
 import java.sql.Driver;
-import java.sql.DriverManager;
 import java.sql.SQLException;
 import org.apache.gravitino.catalog.jdbc.JdbcCatalogOperations;
 import 
org.apache.gravitino.catalog.jdbc.converter.JdbcColumnDefaultValueConverter;
@@ -46,6 +45,6 @@ public class PostgreSQLCatalogOperations extends 
JdbcCatalogOperations {
 
   @Override
   protected Driver getDriver() throws SQLException {
-    return DriverManager.getDriver("jdbc:postgresql://dummy_address:12345/");
+    return super.getDriver();
   }
 }
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSqlCatalog.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSqlCatalog.java
index ca8ec349ef..d7be0ab429 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSqlCatalog.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSqlCatalog.java
@@ -42,8 +42,8 @@ public class PostgreSqlCatalog extends JdbcCatalog {
 
   /** {@inheritDoc} */
   @Override
-  public JdbcCatalog withCatalogConf(Map<String, String> conf) {
-    return super.withCatalogConf(resolveJdbcDatabase(conf, 
PostgreSqlCatalog::databaseFromUrl));
+  protected Map<String, String> prepareConfiguration(Map<String, String> conf) 
{
+    return resolveJdbcDatabase(conf, PostgreSqlCatalog::databaseFromUrl);
   }
 
   @Override
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
index 65e16256a4..b115aa6fad 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
@@ -27,6 +27,7 @@ import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import javax.annotation.Nullable;
 import 
org.apache.gravitino.catalog.jdbc.converter.JdbcColumnDefaultValueConverter;
 import org.apache.gravitino.catalog.jdbc.converter.JdbcTypeConverter;
 import org.apache.gravitino.rel.expressions.Expression;
@@ -73,6 +74,17 @@ public class PostgreSqlColumnDefaultValueConverter extends 
JdbcColumnDefaultValu
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  @Nullable
+  public String fromGravitino(Expression defaultValue) {
+    String sql = super.fromGravitino(defaultValue);
+    if (sql != null && sql.indexOf('\0') >= 0) {
+      throw new IllegalArgumentException("PostgreSQL column default must not 
contain U+0000 (NUL)");
+    }
+    return sql;
+  }
+
   /**
    * Doubles single quotes only. With {@code standard_conforming_strings} on, 
the default since
    * PostgreSQL 9.1, a backslash in a string literal is an ordinary character.
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlExceptionConverter.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlExceptionConverter.java
index 7934308a27..e9c721fcab 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlExceptionConverter.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlExceptionConverter.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.catalog.postgresql.converter;
 import java.sql.SQLException;
 import org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
@@ -35,6 +36,7 @@ public class PostgreSqlExceptionConverter extends 
JdbcExceptionConverter {
   private static final String INVALID_SCHEMA_NAME = "3D000";
   private static final String INVALID_SCHEMA = "3F000";
   private static final String UNDEFINED_TABLE = "42P01";
+  private static final String INSUFFICIENT_PRIVILEGE = "42501";
 
   /**
    * SQLSTATE '08' is the class code of connection exceptions See <a
@@ -43,34 +45,35 @@ public class PostgreSqlExceptionConverter extends 
JdbcExceptionConverter {
    */
   private static final String CONNECTION_EXCEPTION = "08";
 
-  @SuppressWarnings("FormatStringAnnotation")
   @Override
   public GravitinoRuntimeException toGravitinoException(SQLException se) {
     if (null != se.getSQLState()) {
       switch (se.getSQLState()) {
+        case INSUFFICIENT_PRIVILEGE:
+          return new ForbiddenException(se, "%s", se.getMessage());
         case DUPLICATE_DATABASE:
         case DUPLICATE_SCHEMA:
-          return new SchemaAlreadyExistsException(se.getMessage(), se);
+          return new SchemaAlreadyExistsException(se, "%s", se.getMessage());
         case DUPLICATE_TABLE:
-          return new TableAlreadyExistsException(se.getMessage(), se);
+          return new TableAlreadyExistsException(se, "%s", se.getMessage());
         case INVALID_SCHEMA_NAME:
         case INVALID_SCHEMA:
-          return new NoSuchSchemaException(se.getMessage(), se);
+          return new NoSuchSchemaException(se, "%s", se.getMessage());
         case UNDEFINED_TABLE:
-          return new NoSuchTableException(se.getMessage(), se);
+          return new NoSuchTableException(se, "%s", se.getMessage());
         default:
           {
             if (se.getSQLState().startsWith(CONNECTION_EXCEPTION)) {
-              return new ConnectionFailedException(se.getMessage(), se);
+              return new ConnectionFailedException(se, "%s", se.getMessage());
             }
-            return new GravitinoRuntimeException(se.getMessage(), se);
+            return new GravitinoRuntimeException(se, "%s", se.getMessage());
           }
       }
     } else {
       if (se.getMessage() != null && se.getMessage().contains("password 
authentication failed")) {
-        return new ConnectionFailedException(se.getMessage(), se);
+        return new ConnectionFailedException(se, "%s", se.getMessage());
       }
-      return new GravitinoRuntimeException(se.getMessage(), se);
+      return new GravitinoRuntimeException(se, "%s", se.getMessage());
     }
   }
 }
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/operation/PostgreSqlTableOperations.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/operation/PostgreSqlTableOperations.java
index b0fa38be8e..194c7dd66b 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/operation/PostgreSqlTableOperations.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/operation/PostgreSqlTableOperations.java
@@ -26,6 +26,7 @@ import com.google.common.collect.Lists;
 import java.nio.charset.StandardCharsets;
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.util.ArrayList;
@@ -51,6 +52,7 @@ import 
org.apache.gravitino.catalog.jdbc.operation.DatabaseOperation;
 import org.apache.gravitino.catalog.jdbc.operation.JdbcTableOperations;
 import org.apache.gravitino.catalog.jdbc.operation.RequireDatabaseOperation;
 import org.apache.gravitino.catalog.jdbc.utils.JdbcConnectorUtils;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchColumnException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
@@ -60,6 +62,7 @@ import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.expressions.distributions.Distribution;
 import org.apache.gravitino.rel.expressions.distributions.Distributions;
 import org.apache.gravitino.rel.expressions.literals.Literals;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.rel.types.Types;
@@ -102,6 +105,47 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
     this.schemaOperations = (PostgreSqlSchemaOperations) databaseOperation;
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public void create(
+      String schema,
+      String tableName,
+      JdbcColumn[] columns,
+      @Nullable String comment,
+      Map<String, String> properties,
+      @Nullable Transform[] partitioning,
+      Distribution distribution,
+      Index[] indexes,
+      @Nullable SortOrder[] sortOrders) {
+    try {
+      super.create(
+          schema,
+          tableName,
+          columns,
+          comment,
+          properties,
+          partitioning,
+          distribution,
+          indexes,
+          sortOrders);
+    } catch (NoSuchSchemaException e) {
+      // PostgreSQL removes unusable schemas from search_path. An unqualified 
CREATE can therefore
+      // report 3F000 even when the requested schema exists but the source 
role has no USAGE.
+      boolean lacksUsage;
+      try {
+        lacksUsage = schemaLacksUsage(schema);
+      } catch (RuntimeException diagnosisFailure) {
+        diagnosisFailure.addSuppressed(e);
+        throw diagnosisFailure;
+      }
+      if (lacksUsage) {
+        throw new ForbiddenException(
+            e, "Insufficient source privileges for schema %s: USAGE is 
required", schema);
+      }
+      throw e;
+    }
+  }
+
   @Override
   public List<String> listTables(String schemaName) throws 
NoSuchSchemaException {
     try (Connection connection = getConnection(schemaName)) {
@@ -207,7 +251,7 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
           .append(TABLE_COMMENT)
           .append(quotedIdentifier(tableName))
           .append(IS)
-          .append(escapeSqlLiteral(comment, '\''))
+          .append(escapeSqlLiteral(validateText(comment, "table comment"), 
'\''))
           .append("';");
     }
     Arrays.stream(columns)
@@ -221,7 +265,11 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
                     .append(".")
                     .append(quotedIdentifier(jdbcColumn.name()))
                     .append(IS)
-                    .append(escapeSqlLiteral(jdbcColumn.comment(), '\''))
+                    .append(
+                        escapeSqlLiteral(
+                            validateText(
+                                jdbcColumn.comment(), "comment for column " + 
jdbcColumn.name()),
+                            '\''))
                     .append("';"));
 
     // Return the generated SQL statement
@@ -477,7 +525,8 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
 
   private String updateCommentDefinition(
       TableChange.UpdateComment updateComment, JdbcTable jdbcTable) {
-    String newComment = 
StringUtils.defaultString(updateComment.getNewComment());
+    String newComment =
+        StringUtils.defaultString(validateText(updateComment.getNewComment(), 
"table comment"));
     if (null == StringIdentifier.fromComment(newComment)) {
       // Detect and add Gravitino id.
       if (StringUtils.isNotEmpty(jdbcTable.comment())) {
@@ -490,7 +539,7 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
     return TABLE_COMMENT
         + quotedIdentifier(jdbcTable.name())
         + IS
-        + escapeSqlLiteral(newComment, '\'')
+        + escapeSqlLiteral(validateText(newComment, "table comment"), '\'')
         + "';";
   }
 
@@ -650,7 +699,8 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
               + "."
               + quotedIdentifier(col)
               + IS
-              + escapeSqlLiteral(addColumn.getComment(), '\'')
+              + escapeSqlLiteral(
+                  validateText(addColumn.getComment(), "comment for column " + 
col), '\'')
               + "';");
     }
     return result;
@@ -668,7 +718,7 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
         + "."
         + quotedIdentifier(col)
         + IS
-        + escapeSqlLiteral(newComment, '\'')
+        + escapeSqlLiteral(validateText(newComment, "comment for column " + 
col), '\'')
         + "';";
   }
 
@@ -763,6 +813,29 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
     }
   }
 
+  private boolean schemaLacksUsage(String schema) {
+    try (Connection connection = dataSource.getConnection();
+        PreparedStatement statement =
+            connection.prepareStatement(
+                "SELECT pg_catalog.has_schema_privilege(n.oid, 'USAGE') "
+                    + "FROM pg_catalog.pg_namespace n WHERE n.nspname = ?")) {
+      statement.setString(1, schema);
+      try (ResultSet rows = statement.executeQuery()) {
+        return rows.next() && !rows.getBoolean(1);
+      }
+    } catch (SQLException e) {
+      throw exceptionMapper.toGravitinoException(e);
+    }
+  }
+
+  @Nullable
+  private static String validateText(@Nullable String text, String field) {
+    if (text != null && text.indexOf('\0') >= 0) {
+      throw new IllegalArgumentException("PostgreSQL " + field + " must not 
contain U+0000 (NUL)");
+    }
+    return text;
+  }
+
   private static String quotedIdentifier(String name) {
     return PG_QUOTE + name.replace(PG_QUOTE, PG_QUOTE + PG_QUOTE) + PG_QUOTE;
   }
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/TestPostgreSqlCatalogConfiguration.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/TestPostgreSqlCatalogConfiguration.java
index ef1e83047e..160f2d5ebe 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/TestPostgreSqlCatalogConfiguration.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/TestPostgreSqlCatalogConfiguration.java
@@ -36,6 +36,34 @@ import org.junit.jupiter.params.provider.ValueSource;
 /** Tests database resolution before catalog operations are initialized. */
 class TestPostgreSqlCatalogConfiguration {
 
+  @Test
+  void testProposedConfigurationRejectsDatabaseMismatchBeforeInitialization() {
+    Map<String, String> configuration =
+        new HashMap<>(
+            Map.of(
+                "jdbc-url",
+                "jdbc:postgresql://localhost/demo",
+                "jdbc-database",
+                "other",
+                "jdbc-driver",
+                "org.postgresql.Driver",
+                "jdbc-user",
+                "unused",
+                "jdbc-password",
+                "unused",
+                "verify-on-create",
+                "false"));
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> new 
PostgreSqlCatalog().validateConfiguration(configuration));
+    Assertions.assertTrue(error.getMessage().contains("jdbc-database must 
match"));
+    Assertions.assertEquals("other", configuration.get("jdbc-database"));
+    configuration.put("jdbc-database", "demo");
+    Assertions.assertDoesNotThrow(
+        () -> new PostgreSqlCatalog().validateConfiguration(configuration));
+  }
+
   @ParameterizedTest
   @CsvSource(
       value = {
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
index c66b17fa35..ec1825323a 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
@@ -28,6 +28,20 @@ public class TestPostgreSqlColumnDefaultValueConverter {
   private final PostgreSqlColumnDefaultValueConverter converter =
       new PostgreSqlColumnDefaultValueConverter();
 
+  @Test
+  void testNulStringDefaultIsRejectedBeforeDdl() {
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> 
converter.fromGravitino(Literals.stringLiteral("before\0after")));
+    Assertions.assertTrue(error.getMessage().contains("default"));
+    Assertions.assertTrue(error.getMessage().contains("U+0000"));
+    Assertions.assertEquals(
+        "'before\\u0000after'",
+        converter.fromGravitino(Literals.stringLiteral("before\\u0000after")));
+    Assertions.assertEquals("NULL", converter.fromGravitino(Literals.NULL));
+  }
+
   /** Verifies long quoted text defaults can be loaded without recursive regex 
stack growth. */
   @Test
   public void testLongQuotedTextLiteralIsUnescaped() {
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlExceptionConverter.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlExceptionConverter.java
new file mode 100644
index 0000000000..e45514e637
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlExceptionConverter.java
@@ -0,0 +1,79 @@
+/*
+ * 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.gravitino.catalog.postgresql.converter;
+
+import java.sql.SQLException;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** PostgreSQL source permission errors remain distinct from missing objects 
and outages. */
+public class TestPostgreSqlExceptionConverter {
+  @SuppressWarnings("OrphanedFormatString") // Source messages are literal, 
not format templates.
+  @ParameterizedTest
+  @NullSource
+  @ValueSource(
+      strings = {
+        "42501", "42P04", "42P06", "42P07", "3D000", "3F000", "42P01", 
"08006", "08P01", "XX000"
+      })
+  void testSqlFailuresPreserveCauseAndLiteralMessage(String state) {
+    SQLException failure = new SQLException("source failure containing literal 
%s", state);
+    Exception error = new 
PostgreSqlExceptionConverter().toGravitinoException(failure);
+    Assertions.assertSame(failure, error.getCause());
+    Assertions.assertEquals(failure.getMessage(), error.getMessage());
+  }
+
+  @SuppressWarnings("OrphanedFormatString") // Source messages are literal, 
not format templates.
+  @Test
+  void testAuthenticationFailureWithoutSqlStatePreservesCause() {
+    SQLException failure = new SQLException("password authentication failed 
for role %s");
+    Exception error = new 
PostgreSqlExceptionConverter().toGravitinoException(failure);
+    Assertions.assertInstanceOf(ConnectionFailedException.class, error);
+    Assertions.assertSame(failure, error.getCause());
+    Assertions.assertEquals(failure.getMessage(), error.getMessage());
+  }
+
+  @Test
+  void testInsufficientPrivilegePreservesCauseAndReason() {
+    SQLException failure = new SQLException("permission denied for schema 
restricted", "42501");
+    Exception error = new 
PostgreSqlExceptionConverter().toGravitinoException(failure);
+    Assertions.assertInstanceOf(ForbiddenException.class, error);
+    Assertions.assertSame(failure, error.getCause());
+    Assertions.assertEquals(failure.getMessage(), error.getMessage());
+  }
+
+  @Test
+  void testMissingSchemaAndConnectionFailuresRemainDistinct() {
+    PostgreSqlExceptionConverter converter = new 
PostgreSqlExceptionConverter();
+    Assertions.assertInstanceOf(
+        NoSuchSchemaException.class,
+        converter.toGravitinoException(new SQLException("schema missing", 
"3F000")));
+    for (String state : new String[] {"08006", "08P01"}) {
+      Assertions.assertInstanceOf(
+          ConnectionFailedException.class,
+          converter.toGravitinoException(
+              new SQLException("connection or protocol failure", state)));
+    }
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableCreatePrivileges.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableCreatePrivileges.java
new file mode 100644
index 0000000000..304afcd1f9
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableCreatePrivileges.java
@@ -0,0 +1,165 @@
+/*
+ * 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.gravitino.catalog.postgresql.operation;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Map;
+import javax.sql.DataSource;
+import org.apache.gravitino.catalog.jdbc.JdbcColumn;
+import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlColumnDefaultValueConverter;
+import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlExceptionConverter;
+import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlTypeConverter;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.apache.gravitino.rel.indexes.Indexes;
+import org.apache.gravitino.rel.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** A hidden search-path entry must not make an existing inaccessible schema 
look absent. */
+public class TestPostgreSqlTableCreatePrivileges {
+  private DataSource source;
+  private Connection createConnection;
+  private Connection probeConnection;
+  private Statement createStatement;
+  private PreparedStatement probeStatement;
+  private ResultSet rows;
+  private PostgreSqlTableOperations operations;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    source = mock(DataSource.class);
+    createConnection = mock(Connection.class);
+    probeConnection = mock(Connection.class);
+    createStatement = mock(Statement.class);
+    probeStatement = mock(PreparedStatement.class);
+    rows = mock(ResultSet.class);
+    when(source.getConnection()).thenReturn(createConnection, probeConnection);
+    when(createConnection.createStatement()).thenReturn(createStatement);
+    when(createStatement.executeUpdate(anyString()))
+        .thenThrow(new SQLException("no schema has been selected to create 
in", "3F000"));
+    
when(probeConnection.prepareStatement(anyString())).thenReturn(probeStatement);
+    when(probeStatement.executeQuery()).thenReturn(rows);
+    operations = new PostgreSqlTableOperations();
+    operations.initialize(
+        source,
+        new PostgreSqlExceptionConverter(),
+        new PostgreSqlTypeConverter(),
+        new PostgreSqlColumnDefaultValueConverter(),
+        Map.of("jdbc-database", "database"));
+  }
+
+  @Test
+  void testExistingSchemaWithoutUsageIsForbidden() throws Exception {
+    when(rows.next()).thenReturn(true);
+    when(rows.getBoolean(1)).thenReturn(false);
+    ForbiddenException error = 
Assertions.assertThrows(ForbiddenException.class, this::create);
+    Assertions.assertTrue(error.getMessage().contains("restricted"));
+    Assertions.assertTrue(error.getMessage().contains("USAGE"));
+    verify(probeStatement).setString(1, "restricted");
+    verify(createConnection).close();
+    verify(probeConnection).close();
+    verify(probeStatement).close();
+    verify(rows).close();
+  }
+
+  @Test
+  void testMissingSchemaRemainsMissing() throws Exception {
+    when(rows.next()).thenReturn(false);
+    Assertions.assertThrows(NoSuchSchemaException.class, this::create);
+    verify(probeConnection).close();
+  }
+
+  @Test
+  void testExistingUsableSchemaDoesNotInventPermissionFailure() throws 
Exception {
+    when(rows.next()).thenReturn(true);
+    when(rows.getBoolean(1)).thenReturn(true);
+    Assertions.assertThrows(NoSuchSchemaException.class, this::create);
+    verify(probeConnection).close();
+  }
+
+  @Test
+  void testSuccessfulCreateDoesNotBorrowForPermissionProbe() throws Exception {
+    doReturn(0).when(createStatement).executeUpdate(anyString());
+    Assertions.assertDoesNotThrow(this::create);
+    verify(source, times(1)).getConnection();
+    verify(createConnection).close();
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testProbeFailurePreservesCreateFailure(boolean queryFailure) throws 
Exception {
+    SQLException probeFailure = new SQLException("connection lost", "08006");
+    if (queryFailure) {
+      when(probeStatement.executeQuery()).thenThrow(probeFailure);
+    } else {
+      
when(source.getConnection()).thenReturn(createConnection).thenThrow(probeFailure);
+    }
+    ConnectionFailedException error =
+        Assertions.assertThrows(ConnectionFailedException.class, this::create);
+    Assertions.assertSame(probeFailure, error.getCause());
+    Assertions.assertEquals(1, error.getSuppressed().length);
+    NoSuchSchemaException createFailure =
+        Assertions.assertInstanceOf(NoSuchSchemaException.class, 
error.getSuppressed()[0]);
+    SQLException createCause =
+        Assertions.assertInstanceOf(SQLException.class, 
createFailure.getCause());
+    Assertions.assertEquals("3F000", createCause.getSQLState());
+    Assertions.assertEquals("no schema has been selected to create in", 
createCause.getMessage());
+    verify(createConnection).close();
+    if (queryFailure) {
+      verify(probeConnection).close();
+      verify(probeStatement).close();
+    }
+  }
+
+  private void create() {
+    operations.create(
+        "restricted",
+        "t",
+        new JdbcColumn[] {
+          JdbcColumn.builder()
+              .withName("id")
+              .withType(Types.IntegerType.get())
+              .withNullable(true)
+              .build()
+        },
+        null,
+        Map.of(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        Indexes.EMPTY_INDEXES);
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
index 1db20ecf54..54ecea04c4 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
@@ -29,6 +29,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 import javax.sql.DataSource;
+import org.apache.commons.dbcp2.BasicDataSource;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.catalog.jdbc.JdbcColumn;
 import org.apache.gravitino.catalog.jdbc.JdbcTable;
@@ -37,7 +38,9 @@ import 
org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
 import org.apache.gravitino.catalog.jdbc.utils.JdbcConnectorUtils;
 import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlColumnDefaultValueConverter;
 import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlTypeConverter;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
 import org.apache.gravitino.rel.Column;
@@ -197,6 +200,154 @@ public class TestPostgreSqlTableOperations extends 
TestPostgreSql {
     }
   }
 
+  @Test
+  void testLeastPrivilegeCreateDistinguishesMissingSchemaFromForbidden() 
throws SQLException {
+    String role = RandomNameUtils.genRandomName("limited_role");
+    String schema = RandomNameUtils.genRandomName("restricted_schema");
+    String table = RandomNameUtils.genRandomName("permission_table");
+    try (Connection admin = DATA_SOURCE.getConnection()) {
+      JdbcConnectorUtils.executeUpdate(
+          admin, "CREATE ROLE " + role + " LOGIN PASSWORD 
'test-only-password'");
+      JdbcConnectorUtils.executeUpdate(admin, "CREATE SCHEMA " + schema);
+      try {
+        JdbcConnectorUtils.executeUpdate(admin, "REVOKE ALL ON SCHEMA " + 
schema + " FROM PUBLIC");
+        Map<String, String> properties = new HashMap<>();
+        properties.put("jdbc-driver", CONTAINER.getDriverClassName());
+        properties.put("jdbc-url", CONTAINER.getJdbcUrl());
+        properties.put("jdbc-database", admin.getCatalog());
+        properties.put("jdbc-user", role);
+        properties.put("jdbc-password", "test-only-password");
+        properties.put("jdbc.pool.max-size", "1");
+        properties.put("jdbc.pool.min-size", "1");
+        try (BasicDataSource limitedSource =
+            (BasicDataSource) DataSourceUtils.createDataSource(properties)) {
+          PostgreSqlTableOperations limited = new PostgreSqlTableOperations();
+          limited.initialize(
+              limitedSource,
+              JDBC_EXCEPTION_CONVERTER,
+              new PostgreSqlTypeConverter(),
+              new PostgreSqlColumnDefaultValueConverter(),
+              properties);
+          JdbcColumn[] columns = {
+            
JdbcColumn.builder().withName("id").withType(INT).withNullable(true).build()
+          };
+          Assertions.assertThrows(
+              ForbiddenException.class,
+              () ->
+                  limited.create(
+                      schema,
+                      table,
+                      columns,
+                      null,
+                      Map.of(),
+                      null,
+                      Distributions.NONE,
+                      Indexes.EMPTY_INDEXES));
+          JdbcConnectorUtils.executeUpdate(
+              admin, "GRANT USAGE ON SCHEMA " + schema + " TO " + role);
+          Assertions.assertThrows(
+              ForbiddenException.class,
+              () ->
+                  limited.create(
+                      schema,
+                      table,
+                      columns,
+                      null,
+                      Map.of(),
+                      null,
+                      Distributions.NONE,
+                      Indexes.EMPTY_INDEXES));
+          JdbcConnectorUtils.executeUpdate(
+              admin, "GRANT CREATE ON SCHEMA " + schema + " TO " + role);
+          Assertions.assertDoesNotThrow(
+              () ->
+                  limited.create(
+                      schema,
+                      table,
+                      columns,
+                      null,
+                      Map.of(),
+                      null,
+                      Distributions.NONE,
+                      Indexes.EMPTY_INDEXES));
+          Assertions.assertEquals(table, TABLE_OPERATIONS.load(schema, 
table).name());
+          String absent = RandomNameUtils.genRandomName("missing_schema");
+          Assertions.assertThrows(
+              NoSuchSchemaException.class,
+              () ->
+                  limited.create(
+                      absent,
+                      table,
+                      columns,
+                      null,
+                      Map.of(),
+                      null,
+                      Distributions.NONE,
+                      Indexes.EMPTY_INDEXES));
+        }
+      } finally {
+        JdbcConnectorUtils.executeUpdate(admin, "DROP SCHEMA IF EXISTS " + 
schema + " CASCADE");
+        JdbcConnectorUtils.executeUpdate(admin, "DROP ROLE IF EXISTS " + role);
+      }
+    }
+  }
+
+  @Test
+  void testNulValidationPreventsCreateAndPartialAlterDdl() {
+    String table = RandomNameUtils.genRandomName("nul_validation");
+    String invalidTable = RandomNameUtils.genRandomName("nul_create");
+    JdbcColumn[] columns = {
+      
JdbcColumn.builder().withName("col1").withType(VARCHAR).withNullable(true).build()
+    };
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TABLE_OPERATIONS.create(
+                TEST_DB_NAME,
+                invalidTable,
+                columns,
+                "bad\0comment",
+                Map.of(),
+                null,
+                Distributions.NONE,
+                Indexes.EMPTY_INDEXES));
+    
Assertions.assertFalse(TABLE_OPERATIONS.listTables(TEST_DB_NAME).contains(invalidTable));
+    TABLE_OPERATIONS.create(
+        TEST_DB_NAME,
+        table,
+        columns,
+        "original",
+        Map.of(),
+        null,
+        Distributions.NONE,
+        Indexes.EMPTY_INDEXES);
+    try {
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              TABLE_OPERATIONS.alterTable(
+                  TEST_DB_NAME,
+                  table,
+                  TableChange.addColumn(new String[] {"must_not_exist"}, INT),
+                  TableChange.updateComment("bad\0comment")));
+      JdbcTable loaded = TABLE_OPERATIONS.load(TEST_DB_NAME, table);
+      Assertions.assertEquals(1, loaded.columns().length);
+      Assertions.assertEquals("original", loaded.comment());
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              TABLE_OPERATIONS.alterTable(
+                  TEST_DB_NAME,
+                  table,
+                  TableChange.updateColumnDefaultValue(
+                      new String[] {"col1"}, 
Literals.stringLiteral("bad\0default"))));
+      Assertions.assertEquals(
+          Literals.NULL, TABLE_OPERATIONS.load(TEST_DB_NAME, 
table).columns()[0].defaultValue());
+    } finally {
+      TABLE_OPERATIONS.drop(TEST_DB_NAME, table);
+    }
+  }
+
   @Test
   public void testOperationTable() {
     String tableName = RandomNameUtils.genRandomName("op_table_");
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperationsSqlGeneration.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperationsSqlGeneration.java
index 3f2e81eaa7..ef659ec49b 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperationsSqlGeneration.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperationsSqlGeneration.java
@@ -23,6 +23,7 @@ import org.apache.gravitino.catalog.jdbc.JdbcColumn;
 import org.apache.gravitino.catalog.jdbc.JdbcTable;
 import 
org.apache.gravitino.catalog.jdbc.converter.JdbcColumnDefaultValueConverter;
 import org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
+import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlColumnDefaultValueConverter;
 import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlTypeConverter;
 import org.apache.gravitino.dto.requests.TableUpdateRequest;
 import org.apache.gravitino.rel.TableChange;
@@ -47,6 +48,11 @@ public class TestPostgreSqlTableOperationsSqlGeneration {
       super.columnDefaultValueConverter = new 
JdbcColumnDefaultValueConverter();
     }
 
+    private 
TestablePostgreSqlTableOperations(PostgreSqlColumnDefaultValueConverter 
converter) {
+      this();
+      super.columnDefaultValueConverter = converter;
+    }
+
     public String createTableSql(String tableName, JdbcColumn[] columns) {
       return createTableSql(tableName, columns, "comment");
     }
@@ -156,6 +162,105 @@ public class TestPostgreSqlTableOperationsSqlGeneration {
     }
   }
 
+  @ParameterizedTest
+  @ValueSource(strings = {"table", "column", "alterTable", "alterColumn", 
"addColumn"})
+  void testNulCommentsAreRejectedBeforeDdl(String path) {
+    TestablePostgreSqlTableOperations ops = new 
TestablePostgreSqlTableOperations();
+    JdbcColumn column =
+        JdbcColumn.builder()
+            .withName("col1")
+            .withType(Types.StringType.get())
+            .withNullable(true)
+            .build();
+    String bad = "before\0after";
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> {
+              switch (path) {
+                case "table":
+                  ops.createTableSql("t", new JdbcColumn[] {column}, bad);
+                  break;
+                case "column":
+                  ops.createTableSql(
+                      "t",
+                      new JdbcColumn[] {
+                        JdbcColumn.builder()
+                            .withName("col1")
+                            .withType(Types.StringType.get())
+                            .withNullable(true)
+                            .withComment(bad)
+                            .build()
+                      });
+                  break;
+                case "alterTable":
+                  ops.alterTableSql("t", TableChange.updateComment(bad));
+                  break;
+                case "alterColumn":
+                  ops.alterTableSql(
+                      "t", TableChange.updateColumnComment(new String[] 
{"col1"}, bad));
+                  break;
+                case "addColumn":
+                  ops.alterTableSql(
+                      "t",
+                      TableChange.addColumn(new String[] {"col2"}, 
Types.StringType.get(), bad));
+                  break;
+                default:
+                  throw new AssertionError(path);
+              }
+            });
+    Assertions.assertTrue(error.getMessage().contains("comment"));
+    Assertions.assertTrue(error.getMessage().contains("U+0000"));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"create", "add", "alter"})
+  void testNulDefaultsAreRejectedOnEveryDdlPath(String path) {
+    TestablePostgreSqlTableOperations ops =
+        new TestablePostgreSqlTableOperations(new 
PostgreSqlColumnDefaultValueConverter());
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> {
+              switch (path) {
+                case "create":
+                  ops.createTableSql(
+                      "t",
+                      new JdbcColumn[] {
+                        JdbcColumn.builder()
+                            .withName("col1")
+                            .withType(Types.StringType.get())
+                            .withNullable(true)
+                            
.withDefaultValue(Literals.stringLiteral("bad\0default"))
+                            .build()
+                      });
+                  break;
+                case "add":
+                  ops.alterTableSql(
+                      "t",
+                      TableChange.addColumn(
+                          new String[] {"col2"},
+                          Types.StringType.get(),
+                          null,
+                          TableChange.ColumnPosition.defaultPos(),
+                          true,
+                          false,
+                          Literals.stringLiteral("bad\0default")));
+                  break;
+                case "alter":
+                  ops.alterTableSql(
+                      "t",
+                      TableChange.updateColumnDefaultValue(
+                          new String[] {"col1"}, 
Literals.stringLiteral("bad\0default")));
+                  break;
+                default:
+                  throw new AssertionError(path);
+              }
+            });
+    Assertions.assertTrue(error.getMessage().contains("default"));
+    Assertions.assertTrue(error.getMessage().contains("U+0000"));
+  }
+
   @ParameterizedTest
   @ValueSource(booleans = {false, true})
   void testDeleteIndexesDropsOnlyConstraints(boolean ifExists) {
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java 
b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
index 724a391288..967759479d 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -1214,23 +1214,36 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
           lifecycleLock.readLock().lock();
           try {
             checkOpen();
-            CatalogEntity updatedCatalog =
-                store.update(
-                    ident,
-                    CatalogEntity.class,
-                    EntityType.CATALOG,
-                    catalog -> {
-                      CatalogEntity.Builder newCatalogBuilder =
-                          newCatalogBuilder(ident.namespace(), catalog);
-
-                      Map<String, String> newProps =
-                          catalog.getProperties() == null
-                              ? new HashMap<>()
-                              : new HashMap<>(catalog.getProperties());
-                      newCatalogBuilder = updateEntity(newCatalogBuilder, 
newProps, changes);
-
-                      return newCatalogBuilder.build();
-                    });
+            CatalogEntity updatedCatalog;
+            try (CatalogLease lease = acquireCatalogLease(ident)) {
+              updatedCatalog =
+                  store.update(
+                      ident,
+                      CatalogEntity.class,
+                      EntityType.CATALOG,
+                      catalog -> {
+                        CatalogEntity.Builder newCatalogBuilder =
+                            newCatalogBuilder(ident.namespace(), catalog);
+
+                        Map<String, String> newProps =
+                            catalog.getProperties() == null
+                                ? new HashMap<>()
+                                : new HashMap<>(catalog.getProperties());
+                        newCatalogBuilder = updateEntity(newCatalogBuilder, 
newProps, changes);
+
+                        CatalogEntity proposed = newCatalogBuilder.build();
+                        lease
+                            .wrapper()
+                            .classLoader
+                            .withClassLoader(
+                                cl -> {
+                                  
lease.catalog().validateConfiguration(proposed.getProperties());
+                                  return null;
+                                },
+                                IllegalArgumentException.class);
+                        return proposed;
+                      });
+            }
             // Invalidate after store.update() so that any background thread 
that tries to reload
             // the old catalog identifier from the store (after the 
invalidate) will get
             // NoSuchCatalogException instead of stale data. Invalidating 
before the update creates
@@ -1708,6 +1721,9 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
         cl -> {
           validatePropertyForCreate(catalog.catalogPropertiesMetadata(), 
propsToValidate);
           catalog.validateCredentialProviders(propsToValidate);
+          if (propsToValidate != null) {
+            catalog.validateConfiguration();
+          }
           if (propsToValidate == null) {
             // Loading stays lenient for catalogs persisted with unsupported 
providers, so only
             // warn about them.
diff --git a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java 
b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
index 6d23332d80..e373ef717a 100644
--- a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
+++ b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
@@ -217,6 +217,29 @@ public abstract class BaseCatalog<T extends BaseCatalog>
     return ops;
   }
 
+  /**
+   * Validates the initialized configuration without contacting the catalog 
backend.
+   *
+   * @throws IllegalArgumentException if the configuration is invalid
+   */
+  public final void validateConfiguration() {
+    validateConfiguration(conf);
+  }
+
+  /**
+   * Validates the complete proposed catalog configuration without contacting 
its backend.
+   *
+   * <p>Called on explicit create and alter operations, independently of 
optional backend
+   * verification, with the catalog ClassLoader installed. The configuration 
may differ from the
+   * current entity on alter. Implementations must not mutate it, initialize 
operations, open
+   * connections, or retain resources. The default accepts the configuration 
for compatibility.
+   * Existing catalogs are not validated on ordinary reads.
+   *
+   * @param configuration the complete effective configuration
+   * @throws IllegalArgumentException if the configuration is invalid
+   */
+  public void validateConfiguration(Map<String, String> configuration) {}
+
   /**
    * Whether to validate this catalog against its backend at create time 
rather than on first use.
    *
diff --git a/core/src/test/java/org/apache/gravitino/TestCatalog.java 
b/core/src/test/java/org/apache/gravitino/TestCatalog.java
index 6d5a27bee0..b306f91157 100644
--- a/core/src/test/java/org/apache/gravitino/TestCatalog.java
+++ b/core/src/test/java/org/apache/gravitino/TestCatalog.java
@@ -65,6 +65,16 @@ public class TestCatalog extends BaseCatalog<TestCatalog> {
     return new TestCatalogOperations(config);
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public void validateConfiguration(Map<String, String> configuration) {
+    int minimum = 
Integer.parseInt(configuration.getOrDefault("validation-min", "0"));
+    int maximum = 
Integer.parseInt(configuration.getOrDefault("validation-max", "2"));
+    if (minimum > maximum) {
+      throw new IllegalArgumentException("validation-min must not exceed 
validation-max");
+    }
+  }
+
   @Override
   public boolean shouldValidateOnCreate() {
     Map<String, String> properties = entity().getProperties();
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
index 4633fd8e23..0d2287d985 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
@@ -521,6 +521,75 @@ public class TestCatalogManager {
     
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(failedIdent));
   }
 
+  @Test
+  void testConfigurationValidationOnCreateWithoutBackendVerification() {
+    NameIdentifier ident = NameIdentifier.of("metalake", 
"static-invalid-create");
+    Map<String, String> properties =
+        Maps.newHashMap(
+            ImmutableMap.of(
+                PROPERTY_KEY1,
+                "value1",
+                PROPERTY_KEY2,
+                "value2",
+                PROPERTY_KEY5_PREFIX + "1",
+                "value3",
+                "validation-min",
+                "3",
+                "validation-max",
+                "2",
+                TestCatalogOperations.FAIL_INITIALIZE,
+                "true"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            catalogManager.createCatalog(
+                ident, Catalog.Type.RELATIONAL, provider, "comment", 
properties));
+    
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(ident));
+    properties.put("validation-max", "4");
+    // The identifier is reusable after validation rollback. No backend 
initialization is needed.
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager.createCatalog(
+                ident, Catalog.Type.RELATIONAL, provider, "comment", 
properties));
+  }
+
+  @Test
+  void testConfigurationValidationOnAlterUsesCompleteProposedProperties() {
+    NameIdentifier ident = NameIdentifier.of("metalake", 
"static-invalid-alter");
+    Map<String, String> properties =
+        Maps.newHashMap(
+            ImmutableMap.of(
+                PROPERTY_KEY1,
+                "value1",
+                PROPERTY_KEY2,
+                "value2",
+                PROPERTY_KEY5_PREFIX + "1",
+                "value3",
+                "validation-min",
+                "1",
+                "validation-max",
+                "2",
+                TestCatalogOperations.FAIL_INITIALIZE,
+                "true"));
+    catalogManager.createCatalog(ident, Catalog.Type.RELATIONAL, provider, 
"comment", properties);
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> catalogManager.alterCatalog(ident, 
CatalogChange.setProperty("validation-min", "3")));
+    Assertions.assertEquals(
+        "1", 
catalogManager.loadCatalog(ident).properties().get("validation-min"));
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager.alterCatalog(
+                ident,
+                CatalogChange.setProperty("validation-min", "3"),
+                CatalogChange.setProperty("validation-max", "4")));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> catalogManager.alterCatalog(ident, 
CatalogChange.removeProperty("validation-max")));
+    Assertions.assertEquals(
+        "4", 
catalogManager.loadCatalog(ident).properties().get("validation-max"));
+  }
+
   @Test
   public void testCreateCatalogVerifiesBackendViaTestConnection() throws 
Exception {
     // Opted in via validateOnCreate() force-probe, and the probe reports the 
backend unreachable:
diff --git 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
index 40aa8caabe..7611a30778 100644
--- 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
+++ 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
@@ -134,6 +134,9 @@ public class TestCatalogOperations
 
   public static final String FAIL_CREATE = "fail-create";
 
+  /** Forces operations initialization to fail without contacting a backend. */
+  public static final String FAIL_INITIALIZE = "fail-initialize";
+
   public static final String FAIL_TEST = "need-fail";
 
   public static final String VALIDATE_ON_CREATE = "validate-on-create";
@@ -155,7 +158,11 @@ public class TestCatalogOperations
   @Override
   public void initialize(
       Map<String, String> config, CatalogInfo info, HasPropertyMetadata 
propertyMetadata)
-      throws RuntimeException {}
+      throws RuntimeException {
+    if (Boolean.parseBoolean(config.get(FAIL_INITIALIZE))) {
+      throw new IllegalArgumentException("backend rejected catalog 
configuration");
+    }
+  }
 
   @Override
   public void close() throws IOException {}
diff --git a/docs/jdbc-catalog-connection-validation.md 
b/docs/jdbc-catalog-connection-validation.md
index 13cedaafd7..c5ba0307f0 100644
--- a/docs/jdbc-catalog-connection-validation.md
+++ b/docs/jdbc-catalog-connection-validation.md
@@ -65,7 +65,7 @@ it, and closes it. If it fails, the create is rejected and 
rolled back (the cata
 persisted), and the caller receives a connection error that names the backend 
`host:port` and the
 underlying driver message.
 
-To register a catalog whose backend is temporarily unavailable, set the hidden 
catalog property
+To register a catalog whose backend is temporarily unavailable, set the 
catalog property
 `verify-on-create` to `false`, which skips the create-time check:
 
 ```json
@@ -74,6 +74,11 @@ To register a catalog whose backend is temporarily 
unavailable, set the hidden c
 }
 ```
 
+This setting skips only the backend connection probe. Driver/URL 
compatibility, boolean values,
+and pool bounds are still checked on catalog creation and alteration, without 
opening a database
+connection. Invalid alterations leave the stored configuration unchanged.
+
+
 The default is `true` (verify on create). The connection is still opened 
lazily on first use when
 the check is skipped.
 

Reply via email to