In this article ⏷

Links Don't Wait for Hubs

Stub Hub MERGEs For Early Keys And Zero Keys For Missing Ones

October 14, 2026

A Data Vault link records that two business keys met. The trouble starts when the link load runs and one of those keys isn't ready. Sometimes the key is real but its hub hasn't loaded yet: the order line arrived at 01:00 and the product feed lands at 03:00. Sometimes there's no key at all, because the source column was empty. BimlFlex has a setting for each case. With DvInferLinkHub on, or with any column on the source object set to the Stub Hub change type, the link load also generates an insert-only MERGE into every hub the link references, so an early key still gets its hub row. With DvAddZeroKeys on, a reference whose business-key parts are all NULL gets the zero key instead of a hash computed from NULL inputs.

‍

Both settings ship off. This post shows the SQL each one generates and how to tell which problem you actually have.

‍

We covered what business keys and relationships are, and how BimlFlex models them, in an earlier webcast on business keys and relationships. This one picks up where that stopped: what the generated load does when the keys don't behave.

‍

Two Kinds of Broken Reference

‍

Data Vault 2.0 lets hubs, links and satellites load independently. That independence is what makes loading a Snowflake Data Vault in parallel possible, and it's also why a link can get ahead of a hub. Nothing in the pattern says the product hub has to finish before the sales link starts.

‍

So there are two different failures that look alike in a report:

‍

An early-arriving key is a real value. ProductCategoryID = 18 is sitting in the Product source, but HUB_ProductCategory has no row for it yet. It will, once the ProductCategory load runs. Until then, a link row that references it points at nothing, and an inner join from link to hub drops it.

‍

A missing key is no value at all. The Product row has ProductCategoryID = NULL. There's no category to wait for. If the load hashes it anyway, the link column holds a hash computed from NULL inputs. That value doesn't match any hub row and never will.

‍

The first case wants a hub row now. The second wants an agreed stand-in value. Different fixes, and BimlFlex has a separate setting for each.

‍

The Stub Hub MERGE

‍

Turn on DvInferLinkHub for the link's source object and the generated link load gains one extra statement per referenced hub. Setting any column on that source object to the Stub Hub change type triggers the same thing. Using the AdventureWorksLT example from the docs, the Product load writes LNK_Product_ProductCategory and also writes this into the category hub (trimmed, SQL Server form):

‍

MERGE INTO [dv].[HUB_ProductCategory] TGT 

USING 

( 

SELECT SRC.[ProductCategory_SK] AS [ProductCategory_SK] 

,SRC.[ProductCategory_BK] AS [ProductCategory_BK] 

/* plus your configured audit columns */ 

FROM [stg].[Product] AS SRC 

GROUP BY SRC.[ProductCategory_SK] 

,SRC.[ProductCategory_BK] 

) INS 

ON TGT.[ProductCategory_SK] = INS.[ProductCategory_SK] 

WHEN NOT MATCHED THEN 

INSERT ([ProductCategory_SK], [ProductCategory_BK] /* , audit columns */) 

VALUES (INS.[ProductCategory_SK], INS.[ProductCategory_BK] /* , audit columns */); 

‍

It's insert-only. There's no WHEN MATCHED branch, so a hub row that the real ProductCategory load already wrote is never touched. The stub adds a key that's missing and leaves the hub's own load in charge of everything else. The GROUP BY collapses staging to one row per key before the compare, so five hundred products in category 18 produce at most one hub insert, and a rerun finds the key already there.

‍

It's also scoped to what the link references. The load skips the hub the source object already feeds as its own main hub, since that hub gets its keys from the regular hub load for the same source. You'll see stub statements for HUB_ProductCategory in the Product load, and none for HUB_Product.

‍

The stub row carries the key and the business key. It doesn't carry the descriptive attributes, because the Product feed doesn't have them. Those arrive in the category's satellite when its own source loads. That's the standard Data Vault reading of a stub: the key exists and we saw it here first. Descriptive history starts later.

‍

The docs frame the choice in terms of trust. A relational source with enforced foreign keys will deliver every category in the ProductCategory table anyway, so the extra MERGE is redundant work. Files, APIs and anything with timing gaps are where the stub pays for itself.

‍

Zero Keys for Empty References

‍

DvAddZeroKeys handles the other case. When it's on, the generated staging load wraps each link reference key column in a CASE. If every column that makes up that reference's business key is NULL, the column gets the zero key. Otherwise it keeps the hashed value. For a single-part key on SQL Server with the default SHA1 character hash:

‍

,CASE WHEN [ProductCategoryID] IS NULL 

THEN CAST('0000000000000000000000000000000000000000' AS CHAR(40)) 

ELSE STG.[ProductCategory_SK] 

END AS [ProductCategory_SK] 

‍

The test comes from the reference column's FlexToBk expression. BimlFlex reads the columns named in it and ANDs an IS NULL for each one. Parts that aren't columns, such as @@rs for the record source, aren't tested, since they're never NULL. If a part is itself a derived key built with FlexToBk, the check follows that expression down to its columns. Take a reference built from an order key plus the detail ID, where the order key is itself FlexToBk(@@rs,SalesOrderID). It gets this test:

‍

CASE WHEN [SalesOrderID] IS NULL AND [SalesOrderDetailID] IS NULL 

THEN /* zero key */ 

ELSE STG.[SalesOrderDetail_SK] 

END 

‍

Note the AND. A composite key with one NULL part and one populated part is still hashed as a real key. That's a sensible line: SalesOrderID = 43659 with no detail ID is a strange key, but it isn't an absent one. If half-empty keys are a data quality problem in your source, catch them in staging rules. The zero key is reserved for references where there's nothing there at all.

‍

The link load changes too. With DvAddZeroKeys on, the link MERGE adds one predicate to its match condition:

‍

ON TGT.[Product_ProductCategory_SK] = INS.[Product_ProductCategory_SK] 

AND INS.[Product_ProductCategory_SK] <> CAST('0000000000000000000000000000000000000000' AS CHAR(40)) 

WHEN NOT MATCHED THEN 

INSERT ... 

‍

That second line keeps the zero-key value itself out of the key comparison on the link. The CASE lives in the staging load and the predicate lives in the link load, and each reads the setting from its own object. Set DvAddZeroKeys at a scope that covers both the staging objects and the links they feed, so you don't end up with half the behavior.

‍

What the Zero Key Looks Like

‍

The zero key isn't a single hard-coded value. It follows your target platform and your hash settings, so it matches the type of the key column it lands in:

‍

SQL Server, Azure Synapse: CAST('000...' AS CHAR(n)): CAST(0X000... AS BINARY(n))

‍

Snowflake: CAST('000...' AS CHAR(n)): CAST('000...' AS BINARY(n))

‍

The length follows HashAlgorithm. A character key is 32 zeros for MD5, 40 for SHA1 and 64 for SHA2_256. A binary key is cast to 16, 20 or 32 bytes. If DvUseHashKeys is off and the model uses natural keys, the zero key becomes a typed default cast to the primary key's own data type.

‍

If your standards already define a ghost key, DvZeroKeyExpression replaces the generated expression with yours, used verbatim everywhere the zero key appears. The default value @@this means "use the built-in form". The docs carry a warning worth repeating: pick the expression before the first load. Change it after rows exist and the old zero-key rows no longer match the new ones.

‍

A zero key in a link column is only useful if queries can resolve it. The usual Data Vault practice is a matching zero-key row in each hub, so an inner join from link to hub keeps the relationship and reports it as "unknown". Before reporting depends on it, confirm those rows exist in your hubs.

‍

Choosing Between Them

‍

Ask what the NULL or the gap means in the source.

‍

If the key has a value and the hub will get it eventually, you have an early-arriving key. Use DvInferLinkHub, or the Stub Hub change type on the columns that need it. The cost is one extra insert-only MERGE per referenced hub, per link load. Skip it where the source enforces referential integrity and the hub loads reliably.

‍

If the key is empty and no hub row will ever exist for it, you have a missing key. Use DvAddZeroKeys. The cost is a CASE per link reference column in staging, plus agreeing on what the zero key means for the people reading the reports.

‍

Some sources have both problems: an event feed where the customer key is sometimes late and sometimes blank. The two settings are independent, so you can reason about each column on its own terms. For the wider picture of how BimlFlex generates hubs, links and satellites from one model, the Data Vault automation guide walks the full pattern.

‍

Where Each One Is Generated

‍

The stub hub MERGE is part of the generated link load on the SQL targets and on Databricks, where it runs inside the same link notebook. If you're building on Databricks, metadata-driven Data Vault on Databricks covers how those notebooks are organized.

‍

The zero-key CASE described here comes from the generated SQL staging load for SQL Server, Azure Synapse and Snowflake. On other templates, check your generated staging code for the CASE before you rely on it.

‍

This is code you own, so read it. Open the generated link load, find the extra MERGE or the extra predicate, and check what it will do to your hubs before the first run.