When the Pipeline Lives in a Table

Notes to myself on a metadata-driven ELT platform at a large UK general insurer. What the pattern bought us, the seven ways it went wrong, and the thing all seven had in common. Written down because in a year I will remember that it worked and I will have forgotten why it hurt.

Why this post exists. I am writing this for the version of me who picks up a similar platform in twelve months and thinks "metadata-driven, great, I have done this before". I have. It was a good pattern and I would build it again. But almost every incident I worked on there came back to the same structural property, and that is worth having written down rather than half remembered. Everything below is generalised. No employer, no product names, no ticket numbers, and the object names are neutral stand-ins for the real ones.

The setup, briefly

A Snowflake warehouse fed by Matillion, sourcing mostly from a Databricks lake with some direct extracts from an old iSeries platform, some file drops on S3, and a few Snowflake to Snowflake copies. Data moved through four layers on the way in.

  • Landing. A raw, fully materialised copy of whatever the source gave us.
  • Staging. Append, then dedupe.
  • Trans staging. The CDC delta only, the rows that actually changed.
  • Modelled. Slowly changing dimensions on Type 2, and the facts that join to them.

The part worth writing down is that almost none of that was hard-coded. The behaviour of the platform lived in tables, not in pipelines.

Part one: what the metadata actually did

1. It drove ingestion

This was the big one. There was a control table in an admin schema holding one row per source object, and a small number of generic pipelines that read it. Onboarding a new table was an insert. Not a new job, not a code change, not a release.

CONTROL TABLEEXTRACTION_METADATAone row per source objectSOURCE_SYSTEM / SCHEMA / TABLEEXTRACTION_METHODEXTRACTION_ORDERCDC_COLUMN_IDENTIFIERCDC_LAST_VALUE ◀ watermarkWHERE_STATEMENTTRUNCATE_STAGING_FLAGCLONE_TO_LOWER_ENVSIS_ACTIVEONE GENERIC JOBExtract pipelineread rows into a gridvariable, loop, map eachcolumn to a job variableno table names in codereadswrites the new watermark backWAREHOUSE LAYERSLandingraw copy, fully materialisedStagingappend, then dedupeTrans stagingCDC delta onlyModelledSCD Type 2 dimensions and factsloadsOnboarding a new source table is an INSERT into the control table.No new pipeline, no code change, no release. That is the whole point of the pattern, and also the whole problem.

The columns that mattered were roughly these.

SOURCE_SYSTEM, SOURCE_SCHEMA, SOURCE_TABLE
EXTRACTION_METHOD          -- which branch of the generic job to take
EXTRACTION_ORDER           -- dependency sequencing
LANDING_SCHEMA / LANDING_TABLE, and the staging equivalents
CDC_COLUMN_IDENTIFIER      -- which column to watch for change
CDC_LAST_VALUE             -- the watermark, written back after every run
LAST_EXTRACT_DATETIME / NEXT_EXTRACT_DATETIME
INTERVAL_INCREMENT / INTERVAL_UNIT
WHERE_STATEMENT            -- optional source-side filter
ERROR_IF_NO_DATA           -- is an empty extract a failure or not
TRUNCATE_STAGING_FLAG
CLONE_TO_LOWER_ENVS        -- a JSON dict, per environment, Y or N
IS_ACTIVE

The parent pipeline read the whole table into a grid variable, looped, mapped each column onto a job variable, and called one generic child pipeline per row. The child had no idea which table it was loading. It only knew the values it had been handed.

The EXTRACTION_METHOD column is the one I would point at if someone asked what made this design good. It branched between the lake, the iSeries platform, file loads and warehouse to warehouse copies, all inside the same job. Four quite different extraction mechanics, one place to maintain them.

2. It drove retention and redaction

The governance schema held a rules table with a similar shape. One row per rule, and the rules were policy expressed as data rather than as code.

RULE_ID
DOMAIN                     -- so a domain could be run or excluded independently
SCHEMA_NAME / OBJECT_NAME  -- what the rule applies to
SOURCE_KEY_COLUMN_1..8     -- up to eight key columns for the join
TRIGGER_TABLE + join columns
ACTION_QUERY               -- the statement that does the deletion or redaction
RETENTION_PERIOD / RETENTION_UNIT
APPROVER, START_DATE, END_DATE, IS_ACTIVE

A single job selected the active rules into a grid variable and iterated, exactly as ingestion did. One product line ran on its own schedule and was excluded from the standard domain run by a filter on DOMAIN.

What I liked about this: the approver column meant the business signed off a row in a table, and that row was the thing that ran. There was no translation step between the policy and the implementation where someone could quietly get it wrong. What I did not like about it is further down.

3. It carried the operational state

Three more tables did the unglamorous work.

  • Job history for run auditing. The parent extract job queried it to work out whether it had already run today, and therefore whether the next run should be a full refresh or a partial intraday one. The pipeline's control flow was reading its own audit trail.
  • File landing audit for the file-based loads, so a dropped file was processed once and only once.
  • A default key table for recreating the standard unknown-member surrogate keys across dimensions.

And on top of all that, Snowflake's own INFORMATION_SCHEMA, which I ended up leaning on for the coverage analysis described below.

Part two: the seven ways it went wrong

None of these were exotic. That is rather the point. Every one of them is a normal consequence of moving behaviour out of code and into rows.

1. Mapping errors that fail silently

The one that cost the most compute. A column mapping was missing in a merge component, so the value that should have been written back into CDC_LAST_VALUE never arrived. The watermark stayed null. A null watermark fell back to the epoch. Every run therefore extracted the entire dataset from the beginning of time, wrote it all through the layers, and then failed to update the watermark again.

WHAT THE PATTERN IS MEANT TO DO1Read the watermarkCDC_LAST_VALUE2026-04-30 23:002Extract what changedrows newer thanthe watermark only3Write the max backCDC_LAST_VALUE2026-05-01 23:004Next run stays smallone day of rows,a few minutesWHAT ONE MISSING COLUMN MAPPING DID1Read the watermarkCDC_LAST_VALUEis null2Extract everythingnull falls back to1970-01-013Write back is skippedsource column notmapped in the merge4Repeat tomorrowthe full table again,and again, and againNothing failed. Every run returned success.The only symptom was a job that quietly took hours instead of minutes, and a compute bill nobody had linked to it yet.

The job reported success every single day. There was no failed run, no alert, no error in the log. The only visible symptom was a duration that had crept up, on a platform where plenty of jobs are slow for legitimate reasons. It sat there until somebody looked at the run times properly and asked why a delta load was moving that much data.

The lesson I want to keep. In a metadata-driven framework the dangerous failures are not the ones that throw. They are the ones where the framework does exactly what the metadata told it to do, and the metadata was wrong. A green run is not evidence of anything.

2. Watermarks that watch the wrong thing

The incremental filter looked at the source row effective date. That catches changes made in the source system. It does not catch a change to a Type 2 dimension that the fact joins to.

DIMENSION, TYPE 2Version 1key 4471expired 2026-03-14Version 2key 9052current from 2026-03-14the business changed somethingFACT ROWLoaded oncedim_key = 4471source timestamp unchangedNever re-enters the mergethe incremental filter onlylooks at the source timestampResultThe fact still points atkey 4471, a version of thedimension that expiredweeks ago.Every downstream joinis quietly wrong.The watermark was doing exactly what it was told to do.It was told to watch the source. Nobody told it to watch the dimensions the fact depends on, and no test asserted the difference.

So a fact row whose source timestamp had not moved never re-entered the merge, and never picked up the new surrogate key when the dimension version behind it expired. The fact carried on pointing at a dimension row that had been closed off weeks earlier. Every report built on that join was quietly wrong, and again, nothing failed.

This is a design gap rather than a bug. The watermark was doing precisely what it was configured to do. Nobody had asked the question "what else, other than the source, can invalidate this row".

3. Hash key drift

Surrogate keys were generated by hashing a set of business columns. That is a good pattern, right up until the two places that build the hash stop agreeing with each other.

HASH BUILT WHEN THE DIMENSION LOADSDimension sidePOLICY_REFERENCECLAIM_REFERENCEPERIL_CODEHASH BUILT WHEN THE FACT LOOKS UPFact sidePOLICY_REFERENCECLAIM_REFERENCEPERIL_CODEPOLICY_STATUS_CODEadded later, one side onlyshould be identicalDifferent inputone extra columnDifferent hashno match on the joinDefault key -1the unknown memberNo errorthe load reports successThe same failure comes from an inconsistent TRIM or UPPER on one side.A hash is only as good as the agreement between the two places that build it, and nothing in the framework enforces that agreement.

The concrete instance I spent time on: two status code columns had been added to one side of the hash for a household claims dimension and not the other, and a source reference column had not been renamed consistently in the calculator component. The joins stopped matching, and every unmatched row took the default unknown-member key of minus one. No error. A fact table full of minus ones, which is exactly the kind of thing that looks like a data quality issue at the source until you trace it.

The same class of failure comes from an inconsistent TRIM or UPPER on one side of the calculation, or from a genuinely subtle one: including the CDC operation column in the hash. Do that and a delete followed by a re-insert produces a different key for the same business entity, so the load stops being idempotent and reruns start creating duplicates.

4. The framework only covers what is registered in it

This is the failure mode that is hardest to see, because there is nothing to look at. If a table was never inserted into the retention rules, no retention ran against it. No rule, no job, no alert, no evidence. Silence is indistinguishable from compliance.

I ended up writing a notebook against production that did two joins in opposite directions.

-- modelled tables with no retention rule at all
SELECT t.table_schema, t.table_name
FROM   information_schema.tables t
LEFT   JOIN governance.retention_rules_master r
       ON  r.schema_name = t.table_schema
       AND r.object_name = t.table_name
       AND r.is_active   = TRUE
WHERE  t.table_schema IN ( ...modelled schemas... )
AND    r.rule_id IS NULL;

-- rules pointing at objects that no longer exist
SELECT r.rule_id, r.schema_name, r.object_name
FROM   governance.retention_rules_master r
LEFT   JOIN information_schema.tables t
       ON  t.table_schema = r.schema_name
       AND t.table_name   = r.object_name
WHERE  r.is_active = TRUE
AND    t.table_name IS NULL;

Both sides returned rows. That query should not have been a one-off investigation. It should have been a scheduled test that fails the build, and if I set this pattern up again from scratch it will be.

5. Environments that do not match

The pre-production environment cloned the modelled layer down from production every day, but rebuilt the transformation layer from source. So the data composition and the metadata state in pre-production were never quite the same as production.

The practical consequence is that data-sensitive bugs do not reproduce reliably below production. You can have a fix that demonstrably works in pre-production and still does nothing in production, because the row that triggers the problem only exists in one of them. That eats an enormous amount of time and it is very hard to explain to anyone outside the team why "it works in pre" is not the reassurance it sounds like.

6. Coupling and cascade

Junk dimensions used auto-increment surrogate keys while the facts joined to them on hashes. That combination means a junk dimension and its fact are welded together: you cannot truncate and reload one without invalidating every foreign key reference in the other. They have to move as a pair, every time.

This is the single strongest argument I have for deterministic, hash-based keys everywhere. If the key is a function of the business columns, reloading one table in isolation is safe, because the key it regenerates is the key it had before. The cascade disappears. It is also most of the argument for moving that platform onto dbt, where that convention is the default rather than something you have to defend in review.

7. The metadata was not under change control

This one is structural and it is the root of several of the others. Schema changes went through migrations, source control and a pull request. Metadata changes were UPDATE statements against a table.

A DDL changeA metadata change
ReviewPull requestWhoever had access
HistoryFull, in the repositoryThe current row value
DiffVisible before mergeNone
RollbackA revertRemember the old value
Blast radiusOne objectEvery table the row governs

So the thing that determined the behaviour of the platform was the one thing with no version history, no diff and no review gate. A single-character change to a where clause in a control row could alter what a production job loaded that night, and there would be no record that anything had happened.

The thread running through all of it

Six of these seven are the same failure wearing different clothes. Moving behaviour out of code and into data does not remove the complexity, it moves it somewhere that has none of the tooling we normally rely on. Code has tests, reviews, diffs, type checks and a compiler that shouts. Rows in a table have none of that by default. You have to build every one of those safeguards yourself, and the pattern is seductive precisely because it works so well before you have built any of them.

The pattern is still right. Onboarding a table with an insert instead of a release is genuinely worth having, and at the volume of source objects we were dealing with there was no realistic alternative. But the honest version of the trade is this: you exchange code you have to write for data you have to govern, and governing data is not free.

What I would do differently

  • Version the metadata like code. The control tables should be seeded from files in the repository, deployed through the same migration process as everything else. If someone wants to change what a pipeline does, they should have to open a pull request.
  • Deterministic keys everywhere. No auto-increment surrogate keys on anything a fact joins to. It removes the truncate-and-reload cascade completely, and it makes a rerun safe.
  • Assert the hash inputs. A test that compares the column list on both sides of every hash calculation and fails when they diverge. This is a cheap test and it would have caught the whole of failure three.
  • Alert on plausibility, not just on failure. A watermark that has not moved in twenty four hours, a delta load that returned more rows than the table has, a sudden run of minus one keys. All of these are trivially detectable and none of them raise an error on their own.
  • Test the coverage, not just the rules. The registered-versus-existing joins above should be a scheduled check, in both directions.
  • Write down what invalidates a row. For every incremental load, an explicit statement of what can change the correctness of an already-loaded row. If that list is longer than what the watermark actually watches, you have found a bug you have not hit yet.

The short version, for future me

The pattern works

One generic job, a control table, and onboarding becomes an insert. At scale there is no sensible alternative.

Green means nothing

The expensive failures all reported success. Look at durations, row counts and key distributions, not at run status.

Watch what the watermark misses

Source timestamps do not know about dimension changes. Write down what invalidates a row before trusting the filter.

Govern the metadata

If the behaviour lives in a table, then the table needs reviews, diffs and history. Otherwise the most important thing you own is the least controlled.

Get in touch!

What type of project are you interested in?
Where can I reach you?
Where would you like to discuss?