Skip to content

← Training overview

9. NULLs in entities

A subtle production trap: if any entity-ID column in your source DataFrame can be NULL, the rows with NULL keys are silently dropped during ingestion unless you tell tdfs4ds what sentinel value to use instead. This module explains why, and how to declare the substitution.

The problem

In SQL, NULL = NULL is unknown (not true), so an equi-join on an entity-ID column never matches when either side is NULL. The feature-store ingestion MERGE joins the incoming DataFrame to the existing feature table on the entity-ID columns to decide insert-vs-update; rows whose entity ID is NULL therefore neither update an existing row nor insert a new one — they just disappear, without an error.

The same issue surfaces on the SELECT that unpivots the source into the volatile staging table, because the entity columns also serve as a primary index there.

The solution

upload_features accepts an entity_null_substitute dict that maps each entity-ID column to a sentinel:

tdfs4ds.upload_features(
    df=df,
    entity_id=['customer_id', 'merchant_code'],
    feature_names=['amount'],
    entity_null_substitute={
        'customer_id':   -1,         # numeric sentinel
        'merchant_code': 'UNKNOWN',  # string sentinel
    },
)

Under the hood, tdfs4ds rewrites every comparison on those columns as COALESCE(col, sentinel) on both sides — the staging SELECT and the join predicate of the MERGE. NULL rows are now joined and stored under the sentinel value, and downstream queries can find them.

Choosing a sentinel

The sentinel must be a value that cannot collide with a legitimate entity ID. Common safe choices:

Entity-ID type Sentinel
BIGINT / INTEGER -1 (or another large negative reserved value)
VARCHAR 'UNKNOWN', '__NULL__', or a magic UUID
DATE DATE '1900-01-01'
TIMESTAMP TIMESTAMP '1900-01-01 00:00:00'

If a sentinel can collide with real data, you get a worse bug: legitimate rows merge with the NULL bucket.

What if you forget

There is no error and no warning. Inspect the row counts after a load:

import teradataml as tdml
tdml.execute_sql(
    "SELECT COUNT(*) FROM SRC_TABLE WHERE customer_id IS NULL"
).fetchall()

If the count is non-zero and you didn't pass entity_null_substitute, those rows are not in the feature store.

Composability

entity_null_substitute works alongside FilterManager and TimeManager: the substitution is applied at SELECT and MERGE time for every iteration, so segmented and replayed ingestion behave consistently.

Learn more

  • upload_features — the parameter is part of the public signature.
  • Data model — entity-ID semantics and the hidden-tables pattern.