[BUG] ExpectColumnValuesToBeOfType cannot pass on any Nullable(...) ClickHouse column — observed_value is 'Nullable' and no type name matches

Author: joshua-staufferCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbughelp wantedgood first issueready-for-workclaimed

What's wrong

On ClickHouse, ExpectColumnValuesToBeOfType and ExpectColumnValuesToBeInTypeList cannot pass on any Nullable(...) column — which is most columns in a real ClickHouse schema. clickhouse-sqlalchemy reflects Nullable(Int64) as a Nullable type instance whose nested_type is Int64, and Nullable is not a subclass of Int64. GX's ClickHouse type resolution (great_expectations/util.py:960-974, get_clickhouse_sqlalchemy_potential_type) unwraps nested_type on the expected side only (:970-971); the actual column type reaches the comparison at type_comparison.py:116 / :156 still wrapped. So isinstance(Nullable(Int64), Int64) is False, observed_value is the string 'Nullable', and there is no type_ a user can supply that passes — not Int64, not INTEGER, nothing.

Impact

  • Fails closed, with a useless diagnostic. A correct assertion on a correctly typed column fails, and the reported observed_value ('Nullable') is not a type name anyone can ask for. raised_exception is false, so nothing says why.
  • On a non-nullable column the same comparison works and discriminates (Int64 passes, String fails), so users who test against a non-nullable table and then point at production data see the Expectation start failing for no visible reason.
  • Present since the ClickHouse integration landed in 0.17.0 (#7719, 2023) through 1.23.0; confirmed with clickhouse-sqlalchemy 0.3.2 against clickhouse/clickhouse-server:25.8.29.
  • Workaround: declare the column non-nullable — usually not the GX user's call.

Reproduction

Reproduced on 1.23.0 (673082d4b), Python 3.11, against the repository's ClickHouse container (assets/docker/clickhouse). The harness creates every ClickHouse column as Nullable(...), so any integer column it loads exhibits this.

Add to tests/integration/data_sources_and_expectations/expectations/test_expect_column_values_to_be_of_type.py (import ClickHouseDatasourceTestConfig alongside the module's other configs; the test carries its own frame because the module's shared DATA has an all-NULL column the harness cannot currently load into ClickHouse — see Context):

python
# Own frame: the harness cannot load the module's all-NULL column into ClickHouse.
CLICKHOUSE_NULLABLE_DATA = pd.DataFrame({INTEGER_COLUMN: [1, 2, 3]})


@parameterize_batch_for_data_sources(
    data_source_configs=[ClickHouseDatasourceTestConfig()],
    data=CLICKHOUSE_NULLABLE_DATA,
)
def test_success_for_type__Int64_nullable_clickhouse(batch_for_datasource: Batch) -> None:
    """A ``Nullable(Int64)`` column is an Int64 column that admits NULL, and must compare as one.

    The harness creates every ClickHouse column as ``Nullable(...)``. The reflected type is the
    ``Nullable`` wrapper, which is not a subclass of ``Int64``; only the expected side of the
    comparison unwraps ``nested_type``, so ``isinstance(Nullable(Int64), Int64)`` is False and
    ``observed_value`` is the string ``'Nullable'`` -- a name no ``type_`` can satisfy.
    """
    result = batch_for_datasource.validate(
        gxe.ExpectColumnValuesToBeOfType(column=INTEGER_COLUMN, type_="Int64")
    )
    assert result.result["observed_value"] == "Int64"
    assert result.success
bash
pytest tests/integration/data_sources_and_expectations/expectations/test_expect_column_values_to_be_of_type.py::test_success_for_type__Int64_nullable_clickhouse -m clickhouse

Observed:

FAILED ...::test_success_for_type__Int64_nullable_clickhouse[clickhouse]
    assert result.result["observed_value"] == "Int64"
E   AssertionError: assert equals failed
E      -'Nullable'   +'Int64'

The same picture from a standalone table, for contrast with a non-nullable column:

column=i (Int64)           type_='Int64'   -> success=True  observed_value='Int64'
column=i (Int64)           type_='String'  -> success=False observed_value='Int64'
column=n (Nullable(Int64)) type_='Int64'   -> success=False observed_value='Nullable'
column=n (Nullable(Int64)) type_='String'  -> success=False observed_value='Nullable'

Expected:

column=n (Nullable(Int64)) type_='Int64'   -> success=True  observed_value='Int64'
column=n (Nullable(Int64)) type_='String'  -> success=False observed_value='Int64'

Requirements

  1. When ExpectColumnValuesToBeOfType or ExpectColumnValuesToBeInTypeList runs against a ClickHouse Nullable(T) column, the comparison must be made against T: type_="Int64" on a Nullable(Int64) column returns success=True, and type_="String" on the same column returns success=False.
  2. observed_value for a Nullable(T) column must report T's name ('Int64'), so a failing result names a type the user can actually ask for.
  3. Behaviour on non-nullable ClickHouse columns, and on every other dialect, must be unchanged.
  4. The public signatures and parameters of both Expectations must not change.
  5. Both Expectations must have a regression case on a nullable ClickHouse column in their modules under tests/integration/data_sources_and_expectations/expectations/.

Out of scope: the ClickHouse regex branch (#12217); the type-name resolution defect on the isinstance path when the expected name resolves in neither namespace (#12215 — a Nullable column with a typo'd type_ will still hit that after this fix, and that fix covers it); the harness's handling of all-NULL columns on ClickHouse (a test-utility fix, separate); type-vocabulary questions (INTEGER versus Int64 is a naming choice, not this defect).

Context

  • Root cause: util.py:970-971 unwraps nested_type only for the expected type. The actual type flows from the table.column_types metric (expect_column_values_to_be_of_type.py:461-478) into compare_column_type (type_comparison.py:93-117) and compare_column_type_list (:120-157) unchanged; observed_value is type(actual_column_type).__name__, hence 'Nullable'.
  • Fix shape, verified: two lines ahead of the dispatch in compare_column_type (:109) — if hasattr(actual_column_type, "nested_type"): actual_column_type = actual_column_type.nested_type — turn the test above green with Int64 passing, String and INTEGER failing, and observed_value 'Int64'. compare_column_type_list (:134) needs the same unwrap; an unpatched control kept failing there. A single shared spot before both is the cleaner shape. Only ClickHouse's type engine has nested_type, so the check is inert elsewhere; a dialect-name guard is fine if preferred.
  • clickhouse-sqlalchemy 0.3.2: Nullable(Int64).__mro__ is Nullable → ClickHouseTypeEngine → TypeEngine; isinstance(Nullable(Int64), Int64) is False; .nested_type is Int64. LowCardinality(...) wraps the same way and should get the same treatment — not verified here.
  • Ruled out: type-name vocabulary as the cause — on a non-nullable column Int64 and String pass and INTEGER/VARCHAR fail, so the names resolve; only the wrapped actual type fails. Not verified: LowCardinality, Array, and other wrapping types.
  • The module's shared DATA includes an all-NULL column that the integration harness creates as a non-nullable Int32 on ClickHouse (tests/integration/test_utils/data_source_config/sql.py:437-439 sends an all-null column straight to sqltypes.INTEGER, bypassing the dialect's column_type_overrides), so the insert fails before any test runs. That is why the test above carries its own frame, and why this module has never run ClickHouse.
  • Also recorded in tests/integration/data_sources_and_expectations/data_source_backlog.md ("Fifteen candidates were measured…"), which attributes ClickHouse's two type failures to case vocabulary; that is the top layer only — with the right vocabulary this defect still fails both cases.

Source: fivetran/great_expectations