Repository navigation
In SCD_TYPE_2_BY_TIME models, (null -> non-value) values changes are not tracked properly. #5332
Description
Activity
+1 on Postgres engine
Do we have any updates on this bug?
Still reproduces on SQLMesh 0.236.2, and not only on BigQuery or BY_TIME:
SCD_TYPE_2_BY_COLUMN(withcheck_columns) andSCD_TYPE_2_BY_TIMEshareEngineAdapter._scd_type_2, and both lose the NULL. We reproduced it on Spark (Sail 0.7.2, Iceberg).Root cause. In
sqlmesh/core/engine_adapter/base.py,_scd_type_2builds theupdated_rowsCTE. That CTE re-emits every current target row (the version being closed, or the one kept), and it takes each unmanaged column asexp.func( "COALESCE", exp.column(prefixed_unmanaged_columns[i].this, table="joined"), # joined.t_<col>, the target row exp.column(col, table="joined"), # joined.<col>, the new source row ).as_(col)
Suppose the target row exists and its value is NULL. COALESCE then falls through to the source row's value, so the version being closed shows the value that only the next version had.
inserted_rowsstill opens the new version correctly, so version counts and current rows look right. Only the history is wrong, and SCD2 kinds are forward-only and cannot be restated, so the wrong history stays.Minimal repro (BY_COLUMN,
check_columns [v],execution_time_as_valid_from true):load source (k, v) 2026-09-29 (n, NULL) 2026-09-30 (n, 'A') 2026-10-01 (n, 'B') Expected history:
n NULL 2026-09-29 2026-09-30 n 'A' 2026-09-30 2026-10-01 n 'B' 2026-10-01 NULLActual: the first row reads
'A'(n, 'A', 2026-09-29, 2026-09-30). The value→NULL direction (xthen NULL) and unchanged keys come out correct. BY_TIME withupdated_atshows the same NULL→'A' overwrite.On a real dimension (93k symbols, 4 daily loads), 283 of the 574 versions closed in one run were identical to their successor: FMP had filled in a NULL
cik. That is history which never existed.Fix. Prefer the target value whenever a target row exists.
_scd_type_2already usest_<valid_from> IS NULLas its "no target row" test (valid_from_case_stmtin both branches), so use that:--- a/sqlmesh/core/engine_adapter/base.py +++ b/sqlmesh/core/engine_adapter/base.py @@ def _scd_type_2( .with_( "updated_rows", exp.select( *( - exp.func( - "COALESCE", - exp.column(prefixed_unmanaged_columns[i].this, table="joined"), - exp.column(col, table="joined"), - ).as_(col) + exp.Case() + .when( + exp.column(prefixed_valid_from_col.this, table="joined") + .is_(exp.Null()) + .not_(), + exp.column(prefixed_unmanaged_columns[i].this, table="joined"), + ) + .else_(exp.column(col, table="joined")) + .as_(col) for i, col in enumerate(unmanaged_columns_to_types) ),
Patch description.
fix(scd2): keep NULLs in closed versions
updated_rowscoalesced each target column with the source column, so a NULL in the version being closed (or kept) was replaced by the new source value. Now the target value is taken whenever a target row exists (t_<valid_from>is not NULL, the same testvalid_fromalready uses), and the source value only for new keys. This applies to BY_TIME and BY_COLUMN. Rows for new keys, deleted keys and unchanged keys are unaffected. Behaviour change for existing tables: from the next run, closed versions keep their NULLs. History already written is not rewritten.Tests: add a NULL→value→value key to the SCD2 adapter tests and assert that the closed version keeps NULL. Any expected-SQL assertion containing
COALESCE("joined"."t_<col>", "joined"."<col>")needs theCASEform (not checked against the upstream test suite).The existence check uses
t_<valid_from>, nott_<unique_key>: a target row whose key part is NULL never matches the join, and a key-based check would then emit that row with all-NULL source values.We run the same rewrite as an engine-adapter override (
ensure_nulls_for_unmatched_after_join, applied to the finished SCD2 query) until this is released.
Issue:
SCD_TYPE_2_BY_TIMEon BigQuery incorrectly tracks changes of NULL valuesSQLMesh version: 0.216.0
Gateway: BigQuery
Description:
When using an
SCD_TYPE_2_BY_TIMEmodel kind on a BigQuery backend, NULL values in a source record are being incorrectly filled with non-NULL values from a subsequent update for the same unique_key. This behavior appears to be caused by the use ofCOALESCEin the generated merge statement, which does not preserve the intended NULL values from the source data.Steps to reproduce:
source-data.source_dataset.source_table:sqlmesh planto create and populate the target model.Expected behavior:
The initial record from
2024-02-03should retain its NULL value for thea_nullable_valuecolumn.The resulting
project.target_modeltable should contain the following entries:Actual Behavior:
The NULL value in the
a_nullable_valuecolumn for the first record is replaced by the value'A'from the subsequent record.The table is populated with the following incorrect data:
Possible Cause:
The issue likely stems from the generated
CREATE OR REPLACE TABLEstatement, which usesCOALESCEon all columns.This logic incorrectly backfills NULLs with values from later records during the join operation.
Relevant Query Snippet: