Back to the map
DataCore18h

Data pipelines

Moving and reshaping data reliably, on a schedule.

A pipeline is code that runs on a schedule to move data from where it is produced to where it gets used, reshaping it on the way: pull yesterday's records from a database, clean them, write them somewhere a model or a dashboard can read. Nobody starts it by hand. It runs nightly or hourly, whether or not anyone is watching.

Pipelines fail halfway. A run dies after writing half its rows, and the next run either duplicates them or skips them, and nobody notices until a month of numbers is quietly wrong. Everything downstream inherits that: the model trains on it, the eval scores against it, and the dashboard reports it with no sign that anything is amiss.

It builds on SQL and Python and sits under everything that consumes data in bulk: training sets, retrieval corpora, eval sets and the numbers that tell you a launch worked. It pairs with data quality and labelling, which is how you find out what the pipeline did to your data.

The design question is never the run that works, it is what happens when you run it again after it died halfway.

In more detailBatch and streaming ingestion, scheduling, idempotent reruns, backfills, and schema evolution. The recurring lesson is that pipelines fail partially and silently, so the design question is always what happens on the second run after a half-finished first one.

Section 1 of 4, 5h

Moving data reliably

By the end: You can build a pipeline that survives being run twice.

  • Batch and streaming
  • Idempotency and backfills
  • Schema change
  • Data quality checks

A pipeline is a job that moves data from where it is produced to where it is used, on a schedule, without anyone watching. The hard part is not moving it once but surviving everything that goes wrong afterwards: the run that fails halfway, the run that fires twice, the upstream column that quietly changes type. This section is about building one that can be re-run safely, which is the property that separates a pipeline from a script.

What went wrong on Monday                    What the pipeline needed
the job died after writing half the rows     replace a partition, never append
a bug was found in last quarter's numbers    a date parameter, not "yesterday"
upstream added a column                      a schema check that tolerates additions
upstream turned an integer into a string     a schema check that fails on types
the export ran but produced 40 rows          a volume check against recent history
an event arrived three days late             a trailing reprocess window
Worth memorising. Each line is a real morning, and each has exactly one fix.

Pipelines fail partially#

The defining problem is that a pipeline stops halfway. Some data was written, some was not, and nothing recorded where it got to. Nothing in the target says which rows are from the failed run, because rows do not carry that. Every design decision follows from asking what happens on the second run.

Make reruns safe#

An idempotent pipeline produces the same result whether it runs once or five times. That is a property of how you write, not a claim you make in a docstring, so here it is in code. The target is a list of rows tagged with the day they belong to, and the difference between the two loaders is one line.

def load_append(table, day, rows):
    return table + [(day, r) for r in rows]

def load_replace(table, day, rows):
    kept = [row for row in table if row[0] != day]    # drop the day first
    return kept + [(day, r) for r in rows]

rows = ["a", "b", "c"]

t = []
t = load_append(t, "2026-03-01", rows)
t = load_append(t, "2026-03-01", rows)               # the retry after a timeout
print(len(t))                                        # 6: the day is counted twice

t = []
t = load_replace(t, "2026-03-01", rows)
t = load_replace(t, "2026-03-01", rows)              # the same retry
print(len(t))                                        # 3: identical to one run
The delete-then-insert is the whole mechanism. In a warehouse it is DELETE WHERE date = :day followed by the INSERT, in one transaction; on a filesystem it is replacing a directory.

That is only half of it. Delete-then-insert as two visible steps leaves a window in which the day exists but is empty, and a crash inside that window is worse than the duplicate it was meant to prevent: nothing errors, and a reader sees a real zero. Build the new state first, then publish it with one operation that either happens or does not.

def build(table, day, rows, fail_after=None):
    """Compute the whole new state. Publishing is the caller's single assignment."""
    staged = [row for row in table if row[0] != day]
    for i, r in enumerate(rows):
        if fail_after is not None and i == fail_after:
            raise RuntimeError("source timed out")
        staged.append((day, r))
    return staged

table = build([], "2026-03-01", ["a", "b", "c"])
try:
    table = build(table, "2026-03-02", ["d", "e", "f"], fail_after=1)
except RuntimeError:
    pass                                   # the assignment never ran

print(len(table))                          # 3: day two published nothing at all
print(sorted({d for d, _ in table}))       # ['2026-03-01']
A failed run must leave the target exactly as it found it. One transaction in a database, one directory rename on a filesystem, one atomic swap of a table pointer in a warehouse.

Backfills#

At some point you will reprocess two years of history, usually because a transform was wrong. The loop over dates is the easy part. What actually bites is everything around it.

  • The job must take a date. A pipeline that reads the clock cannot be backfilled at all, and rewriting it to take one is the first day of the work.
  • Direction matters. Run forwards if a day's output depends on the day before it; run backwards from today if the recent months are the ones people are looking at.
  • Throughput. Seven hundred days launched at once will saturate the source database and page whoever owns it. Cap the concurrency and run it in chunks.
  • Cost. Seven hundred times the daily cost, paid in an afternoon rather than over two years. Work it out before you start, not from the invoice.
  • The table is live while you rewrite it. For hours, readers see some days on the old logic and some on the new. Either say so, or backfill into a copy and swap at the end.
  • It will be interrupted. Keep a ledger of finished days so the resume costs minutes rather than starting again.
import datetime as dt

def daterange(start, end):
    day = start
    while day <= end:
        yield day
        day += dt.timedelta(days=1)

def backfill(run, start, end, ledger, limit=None):
    """Resumable. Takes the same run(day) the scheduler calls every morning."""
    done = set(ledger.read())
    processed = 0
    for day in daterange(start, end):
        if day.isoformat() in done:
            continue
        run(day)                              # publishes the day, atomically
        ledger.mark(day.isoformat())          # only now is it done
        processed += 1
        if limit is not None and processed >= limit:
            break                             # one chunk per invocation
    return processed

class MemoryLedger:
    def __init__(self): self.done = []
    def read(self): return list(self.done)
    def mark(self, key): self.done.append(key)

seen, ledger = [], MemoryLedger()
backfill(seen.append, dt.date(2026, 3, 1), dt.date(2026, 3, 10), ledger, limit=4)
backfill(seen.append, dt.date(2026, 3, 1), dt.date(2026, 3, 10), ledger)
print(len(seen), len(set(seen)))              # 10 10: nothing ran twice
The ledger entry is written after the publish. Written before, an interrupted backfill marks a day done that was never written, and that day stays wrong forever.

Schemas change#

Three things can happen to an upstream schema and only one of them is safe. A column is added, which nothing downstream reads and nothing breaks. A column is removed or renamed, which breaks every consumer that reads it, loudly and immediately. Or a column keeps its name and changes type, which breaks nothing at load time and quietly corrupts everything computed from it. The third is the one worth writing code against.

EXPECTED = {"order_id": "int", "user_id": "int", "total": "float", "country": "str"}

def kind_of(value):
    return {bool: "bool", int: "int", float: "float", str: "str"}.get(type(value), "other")

def diff_schema(expected, row):
    observed = {k: kind_of(v) for k, v in row.items() if v is not None}
    added = [k for k in observed if k not in expected]
    missing = [k for k in expected if k not in row]
    changed = [
        (k, expected[k], observed[k])
        for k in observed
        if k in expected and observed[k] != expected[k]
        and not (expected[k] == "float" and observed[k] == "int")   # widening is safe
    ]
    return added, missing, changed

row = {"order_id": 7, "user_id": 31, "total": "19.99", "country": "DE", "channel": "app"}
added, missing, changed = diff_schema(EXPECTED, row)
print(added)      # ['channel']                   log it, carry on
print(missing)    # []                            a removal would be here
print(changed)    # [('total', 'float', 'str')]   stop the run
An integer where a float is expected is the one widening that is safe, so it is excluded. The quoted total is the change that gets through everything else: valid JSON, a clean load, and every sum after it either errors or concatenates.

The policy that follows is dull and works. Additions are allowed without ceremony. Type changes stop the run. A rename is not one change, it is three separated in time, and doing all three in one release is an outage.

week 0   producer writes both country and country_code     nothing breaks
week 1   consumers move to country_code, one at a time      country still written
week 3   producer stops writing country                     nothing reads it
         schema version bumped, old version deleted
Version the schema and support the previous version for the length of the migration, not for the length of the deploy.

Quality checks in the pipeline#

Two kinds, and most pipelines only have the first. Absolute checks say what must always be true of a batch. They catch broken data.

def check(df, expected_rows):
    assert len(df) > 0, "empty batch"
    assert df.id.is_unique, "duplicate ids"
    assert df.total.notna().all(), "missing totals"
    assert (df.total >= 0).all(), "negative totals"
    # Volume check catches upstream failures that produce valid but tiny data
    assert len(df) > 0.5 * expected_rows, f"only {len(df)} rows, expected {expected_rows}"
Pandas, for the shape most pipelines are actually written in. The volume check is the one that catches a broken upstream export that still produces structurally valid rows.

Relative checks compare today against what the last few runs looked like. They catch data that is not broken, just wrong: a source that silently started filtering, a partner that changed a currency, a region that stopped reporting. Compare against the median of recent runs rather than the mean, or one bad day raises the bar for the whole week after it.

def check_relative(today, history, tolerance=0.5):
    """history: row counts from the last successful runs, most recent last."""
    if len(history) < 7:
        return                                   # not enough to judge yet
    typical = sorted(history)[len(history) // 2]
    if today < typical * tolerance or today > typical / tolerance:
        raise ValueError(f"{today} rows against a typical {typical}")

recent = [4000, 4200, 3900, 4100, 4050, 4300, 3950]
check_relative(4100, recent)                     # fine
try:
    check_relative(40, recent)                   # the export that finished early
except ValueError as e:
    print(e)                                     # 40 rows against a typical 4050
Half and double is a deliberately loose band. Tighten it once you know the weekly shape, or the Sunday dip will page you every week.

Fail the pipeline, do not write bad data

Writing suspect data and flagging it means downstream consumers use it before anyone reads the flag. Stopping is disruptive and recoverable; silent corruption spreads and is not. The check belongs between the transform and the publish, on the staged result, so a failure leaves yesterday's good data in place.

Batch or streaming#

Batch accumulates data and processes a period of it. Streaming processes each event as it arrives. The difference people quote is latency, but the difference that decides the design is what you can redo. A batch job's input for a period is a fixed set of files that still exist, so a rerun is exact. A streaming job's input is a position in a log, redoing means replaying from an offset, and everything the job already emitted has already been acted on downstream.

                     Batch                        Streaming
unit of work         a period: an hour, a day     one event
rerun                reprocess the period         rewind to an offset, replay
correct on retry     free, if you replace         only if every write is idempotent
late data            lands in a later run         needs a watermark and a policy
cost when idle       nothing                      the cluster runs all night
debugging            rerun it locally on files    reproduce a moment in a stream
people to operate    one                          a rota
The row that decides it is the second one. Streaming does not remove the idempotency requirement, it moves it from one partition replace to every downstream write.

So the question is whether freshness changes a decision. Fraud, alerting and live personalisation, yes. Reports, training data and nearly all analytics, no. Ask whether anyone would act differently if this data were an hour old, and if the answer is no, run it hourly. A five-minute batch gets most of the benefit of streaming while keeping batch's recovery story, which is why micro-batch is where a lot of supposedly streaming systems end up.

Where people go wrong

Appending instead of replacing. The first rerun after a failure silently doubles a day of data, and the error surfaces weeks later as an inexplicable spike in a dashboard.

Build this: Build a pipeline that processes a date range, make it idempotent, add quality checks, then run it twice on the same date and confirm the output is identical.

Open this section on its own page
Section 2 of 4, 4h

Orchestration, streaming and lineage

By the end: You can choose between batch and streaming and keep track of where data came from.

  • Batch versus streaming
  • Orchestrators and dependencies
  • Late and out-of-order data
  • Lineage

A pipeline that runs once is a script. Making it run every day, in the right order, and tell you exactly which step failed is a different job. This section covers choosing between batch and streaming honestly, since streaming sounds better and is considerably harder to operate; what an orchestrator gives you beyond a cron entry; what to do with events that arrive late or out of order; and lineage, which is being able to answer where a number came from when someone disputes it.

Batch or streaming#

Batch processes accumulated data on a schedule. Streaming processes each event as it arrives. Streaming sounds better and is much harder to operate. Choose it only when freshness genuinely matters to the decision the data feeds.

Batch, hourly or daily       reports, training data, most analytics
Streaming, seconds           fraud, alerting, live personalisation

Ask: would anyone act differently if this data were an hour old?
If not, batch.

The part that is easy to underestimate is the operating cost. A batch job that fails is a red square and a rerun in the morning. A streaming job that fails is consumer lag climbing while someone works out whether it is safe to restart from the last committed offset, and lag that grows faster than the job can catch up is an outage with no natural end. Exactly-once, where a framework offers it, holds inside that framework's own state and its own sinks. The moment the job calls an external API or writes to a database it does not manage, you are back to at-least-once and the write has to be idempotent yourself.

Orchestration#

Pipelines have dependencies: this table needs that one to finish first. An orchestrator runs steps in dependency order, retries failures, and records what ran. The value is less in running things and more in knowing, after a failure, exactly which steps completed. The core of one is smaller than the products suggest, and writing it once makes clear what you are actually buying.

def run_dag(tasks, deps, attempts=2):
    """tasks: name -> callable. deps: name -> names that must finish first."""
    state = {name: "pending" for name in tasks}

    def ready(name):
        return all(state[d] == "ok" for d in deps.get(name, ()))

    progressed = True
    while progressed:
        progressed = False
        for name, fn in tasks.items():
            if state[name] != "pending" or not ready(name):
                continue
            for attempt in range(attempts):
                try:
                    fn()
                    state[name] = "ok"
                    break
                except Exception as e:
                    state[name] = f"failed: {e}"
            progressed = True

    # Anything still pending was blocked by an upstream failure, not broken itself.
    for name in state:
        if state[name] == "pending":
            state[name] = "skipped: upstream failed"
    return state

calls, tried = [], {"clean": 0}

def extract():
    calls.append("extract")

def clean():
    tried["clean"] += 1
    if tried["clean"] == 1:
        raise RuntimeError("bad row")      # fails the first time, then works
    calls.append("clean")

def report():
    calls.append("report")

tasks = {"extract": extract, "clean": clean, "report": report}
deps = {"clean": ["extract"], "report": ["clean"]}
print(run_dag(tasks, deps))
# {'extract': 'ok', 'clean': 'ok', 'report': 'ok'}  -- clean failed once and retried
print(calls)                               # ['extract', 'clean', 'report']
Retries and the distinction between failed and skipped are the whole product. A step that never ran is not a step that broke, and conflating them is how an incident takes an extra hour.

What a real orchestrator adds on top is a scheduler that knows about dates, parallel execution with a concurrency cap, a record of every past run, and a page that shows which day of which task is red. Cron gives you none of that and is fine for one independent job. The moment a second job reads the first one's output, you need the dependency edge, because otherwise the second job runs on yesterday's data whenever the first one is late and nothing anywhere says so.

Late and out-of-order data#

Events arrive late, out of order, or twice. A phone was in a tunnel; a partner batches their exports; a retry duplicated a send. A pipeline that assumes ordered arrival produces aggregates that were right when computed and are wrong an hour later, which is the worst combination because nothing errors and the number moves after someone quoted it.

There are two clocks and keeping them apart is most of the battle. Event time is when the thing happened. Processing time is when your job saw it. Aggregate by event time or the Monday figure includes whatever straggled in on Tuesday. A watermark is your declared position in event time: the point past which you promise not to accept anything more.

from collections import defaultdict

def aggregate(events, allowed_lateness=2):
    """events: (event_time, processing_order) pairs, in arrival order.
    Counts per event-time bucket, with a watermark that closes old buckets."""
    counts, watermark, dropped = defaultdict(int), None, []
    for event_time, _ in events:
        watermark = event_time if watermark is None else max(watermark, event_time)
        if event_time < watermark - allowed_lateness:
            dropped.append(event_time)          # too late: count it separately
            continue
        counts[event_time] += 1
    return dict(counts), dropped

arrivals = [(10, 0), (11, 1), (9, 2), (12, 3), (8, 4), (12, 5)]
print(aggregate(arrivals))
# ({10: 1, 11: 1, 9: 1, 12: 2}, [8])  -- the event from time 8 arrived after the
# watermark reached 12, so it is dropped rather than silently changing a closed bucket
The lateness allowance is a business decision, not a tuning parameter. Two seconds for an alert, two days for a partner feed that exports on a schedule you do not control.

Dropping is one policy of three and you must choose explicitly. Drop, and the bucket is stable but slightly under-counts. Accept and restate, and the bucket is accurate but a number in a dashboard changes after someone screenshotted it. Send late events to a side output and correct in a nightly batch, which is what most systems that care about both end up doing. Say which you chose in the documentation the analysts read, because they will find the discrepancy before you do.

Duplicates are a separate problem from lateness

At-least-once delivery means the same event can arrive twice, minutes or hours apart. The fix is not ordering, it is a deduplication key: give every event an id at the producer, and keep a set of ids seen within the lateness window. Without a producer-assigned id there is nothing to deduplicate on, and adding one later means a migration.

Lineage#

Lineage records which inputs produced which outputs. When a number in a dashboard is wrong, lineage lets you trace it to the source rather than guessing. For ML it answers the question every audit asks: exactly which data was this model trained on. It is cheap to record at write time and impossible to reconstruct afterwards, which is why it is always missing when it is first needed.

import hashlib, json, datetime as dt

def manifest(output_path, inputs, code_version, row_count):
    """Write this next to every output. It is the whole of lineage at one step."""
    return {
        "output": output_path,
        "written_at": dt.datetime.now(dt.timezone.utc).isoformat(),
        "code_version": code_version,          # git sha of the transform
        "inputs": [
            {"path": p, "rows": n, "content_hash": h} for p, n, h in inputs
        ],
        "rows": row_count,
    }

def digest(rows):
    return hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest()[:12]

raw = [{"id": 1, "total": 10}, {"id": 2, "total": 5}]
print(manifest(
    "warehouse/daily/date=2026-03-01",
    [("raw/orders/date=2026-03-01", len(raw), digest(raw))],
    code_version="a91f0c3",
    row_count=len(raw),
))
The content hash is what makes it useful. Path and row count tell you what you read; the hash tells you whether it has changed since, which is the question asked when two runs of the same day disagree.

Lineage is what makes a model reproducible

Without it, you cannot retrain the same model, cannot explain a result, and cannot remove data a user asked to be deleted. That last one is not a nice-to-have: a deletion request obliges you to find every derived table and every training set the record reached, and without lineage the honest answer is that you cannot. It is dull to build and indispensable later.

Where people go wrong

Building a streaming pipeline for data nobody needs in real time. It costs far more to run and debug than a batch job that would have served the same decisions.

Build this: Build the same aggregation as a daily batch and as a stream, then feed both late and duplicated events. Compare the results and the effort.

Open this section on its own page
Section 3 of 4, 5h

A daily pipeline you can rerun safely

By the end: You can write a daily load that can be rerun or backfilled for any date without duplicating or losing data.

  • Partition by date
  • Write, check, then publish
  • Replace the partition, never append blindly
  • Backfills as a loop over dates
  • Scheduling it

A scheduled job that loads yesterday's data will fail one morning, and someone will run it again. This section is about writing it so the rerun is harmless: partition the output by date, compute into a temporary location, check the result, and replace a whole day at once instead of appending. Get that property and every failure has the same fix, which is to run the day again, and backfilling a bug fix across three months is the same code in a loop rather than a special path.

The shape#

extract day D from source  ->  transform  ->  check  ->  replace partition D in the target
                                                     |
                                                     fail: publish nothing, alert

In code#

import duckdb, datetime as dt, pathlib, shutil

def run(day: dt.date):
    src = f"raw/calls/date={day}/*.json"
    out = pathlib.Path(f"warehouse/daily_usage/date={day}")
    tmp = out.with_name(out.name + ".tmp")

    con = duckdb.connect()
    df = con.sql(f"""
        SELECT user_id, model, sum(input_tokens) AS input_tokens,
               sum(output_tokens) AS output_tokens, count(*) AS calls
        FROM read_json_auto('{src}')
        WHERE status = 'ok'
        GROUP BY ALL
    """).df()

    check(df, day)                                  # raises on bad data
    tmp.mkdir(parents=True, exist_ok=True)
    df.to_parquet(tmp / "part.parquet")
    if out.exists():
        shutil.rmtree(out)
    tmp.rename(out)                                 # publish: replace the whole day

def check(df, day):
    assert len(df) > 0, f"{day}: no rows"
    assert (df["input_tokens"] >= 0).all(), f"{day}: negative tokens"
    assert df["user_id"].notna().all(), f"{day}: missing user ids"

Because each run replaces exactly one day's partition, running it twice gives the same result as running it once. That property, idempotency, is what makes failures boring: rerun the day. Three details in that code are doing the work and are worth naming, because leaving any one of them out gives you a pipeline that looks idempotent and is not.

  • The check runs on the staged result, before anything is published. Check after publishing and you have already corrupted the day you are about to complain about.
  • The write goes to a temporary directory with a different name, so a half-written file is never at the path readers use.
  • The last step is a rename, which the filesystem performs as one operation. Writing directly into the output directory and deleting the extra files afterwards is two operations, and a crash between them leaves a day that is part old and part new.

That third point has a limit worth knowing. The rmtree then rename above is itself two operations, so a crash in the gap leaves the day missing entirely rather than mixed. Missing is recoverable and mixed is not, which is why it is written in that order, but if you need the swap to be genuinely atomic then write to a new versioned directory and move a pointer, which is what table formats such as Iceberg and Delta do for you.

Proving it, rather than believing it#

Idempotency is a claim about behaviour under failure, so the test has to inject a failure. This runs without a warehouse: a dict stands in for the target, and the load is killed at a chosen row.

def load(target, day, rows, die_at=None):
    """Stage the whole day, then publish with one assignment."""
    staged = []
    for i, row in enumerate(rows):
        if die_at is not None and i == die_at:
            raise RuntimeError("killed mid-run")
        staged.append(row)
    target[day] = staged                       # the publish
    return target

target, rows = {}, [1, 2, 3, 4]

for die_at in (None, 0, 2, 3):                 # clean run, then three crash points
    try:
        load(target, "2026-03-01", rows, die_at=die_at)
    except RuntimeError:
        pass

print(target)                                  # {'2026-03-01': [1, 2, 3, 4]}
print(sum(target["2026-03-01"]))               # 10, after four runs and three crashes
The assertion that matters is not that the last run succeeded, it is that the total is 10 and not 40. Run it, then move the publish inside the loop and watch it become 10 on a good day and 3 on a bad one.

When the target is a table, not files#

Same property, different mechanism. Delete the day's rows and insert the new ones inside one transaction, so no reader ever sees the gap and a failure rolls the whole thing back.

BEGIN;
DELETE FROM daily_usage WHERE usage_date = :day;
INSERT INTO daily_usage
SELECT :day, user_id, model, sum(input_tokens), sum(output_tokens), count(*)
FROM raw_calls
WHERE call_date = :day AND status = 'ok'
GROUP BY user_id, model;
COMMIT;
One transaction, so a crash anywhere leaves the previous day's version in place. Without the BEGIN this is the two-step delete-then-insert, and the gap between them is visible to every reader.

The alternative is a merge on a natural key, which upserts row by row rather than replacing a period. It is the right shape when rows update individually, for instance a customer record that changes. It is the wrong shape for a daily aggregate, because a row that disappears from the source is never deleted by a merge: it simply keeps its old value forever, and nobody notices until someone reconciles the totals.

Data that keeps arriving#

Most sources are not finished with a day when the day ends. Payments settle, a partner exports late, a mobile client syncs when it next has signal. If the job only ever runs yesterday, those records land in a partition that was already published and are never counted.

def daily(today: dt.date, lookback: int = 7):
    """Reprocess a trailing window, not one day. Safe only because run() replaces."""
    for offset in range(1, lookback + 1):
        run(today - dt.timedelta(days=offset))
Seven times the cost of one day, and the only reason it is affordable is that the cost of a day is small. Choose the window from how late the source actually is, measured, not assumed.

This is the concrete payoff of idempotency. Without it, reprocessing a week every night would multiply a week of data by seven; with it, the trailing window is free of consequences and the only question is compute. It is also why the cost of the property is worth paying up front, before you know you need the window.

Backfills#

def backfill(start: dt.date, end: dt.date):
    day = start
    while day <= end:
        run(day)
        day += dt.timedelta(days=1)
A bug fix to the transform is deployed, then the affected range is backfilled. No special code path.

That loop is correct and will not survive contact with two years of history. Add three things before you start one: a ledger of days already finished so an interruption resumes, a concurrency cap so the source database is not saturated, and a number for what it will cost, obtained by timing one day and multiplying. The last is the one people skip, and the invoice arrives a month later attached to nobody's name.

The table is live while you rewrite it

For as long as a backfill runs, the output contains some days computed the old way and some the new way. Any report over that range is a mixture and its number is meaningless. Either tell the people reading it and pick a quiet window, or backfill into a separate table and swap at the end, which costs storage and removes the problem entirely.

Scheduling#

An orchestrator such as Airflow, Dagster or Prefect runs run(yesterday) every morning, retries on failure, runs backfills in parallel, and shows which days succeeded. Cron works for one job; an orchestrator earns its keep when jobs depend on each other.

Two settings decide whether it wakes you at three in the morning. Retries should be bounded and spaced, because the usual cause of a failure is an upstream system that is down and will not be up in ten seconds. And a job needs an alert on not having run at all, not only on having failed: a scheduler that stopped firing produces no failures, no red squares, and a table that quietly stops growing.

Where people go wrong

Appending each run's output to a table. The first retry duplicates data, and nobody notices until the numbers are questioned.

Build this: Build this pipeline over a week of sample JSON files. Kill it mid-run, rerun, and prove the output is identical. Change the transform, backfill the week, and confirm only the intended numbers changed.

Open this section on its own page
Section 4 of 4, 4h

Pipelines for AI: keeping a RAG index in sync

By the end: You can build an ingestion pipeline that adds, updates and deletes documents in a vector index incrementally and correctly.

  • Detecting changes with content hashes
  • Deletions, the part everyone forgets
  • Quarantining documents that fail to parse
  • Carrying permissions and metadata
  • Re-embedding when the model changes

A vector index is a copy of your documents, and copies go stale. This section is the ingestion job that keeps one honest as the source changes: working out which documents actually changed, replacing their chunks, removing the ones that have been deleted, and keeping permissions attached to every chunk. Deletion is the half that is usually missing, and it is the half that has an assistant confidently citing a policy withdrawn last month to someone who was never allowed to read it.

Only process what changed#

import hashlib

def sync(source_docs, index):
    seen = set()
    for doc in source_docs:                       # id, text, updated_at, acl
        seen.add(doc.id)
        digest = hashlib.sha256(doc.text.encode()).hexdigest()
        if index.stored_hash(doc.id) == digest:
            continue                              # unchanged: skip parsing and embedding
        chunks = chunk(parse(doc))
        index.replace_document(doc.id, embed(chunks), digest=digest, acl=doc.acl)

    for stale_id in index.document_ids() - seen:  # gone from the source
        index.delete_document(stale_id)

The hash is what makes this affordable. Embedding is the expensive step, and on a corpus of any size almost nothing changes between runs, so skipping unchanged documents takes a nightly sync from hours to minutes. Hash the content you actually embed, after parsing and normalising, not the raw bytes: a PDF re-exported with a new timestamp has different bytes and identical text, and hashing the bytes re-embeds the whole corpus every time someone touches the export job.

import hashlib

def fingerprint(text, acl, chunk_size, model):
    """Everything that, if changed, means the stored chunks are wrong."""
    parts = [text, ",".join(sorted(acl)), str(chunk_size), model]
    return hashlib.sha256("|".join(parts).encode()).hexdigest()

doc = dict(text="Refunds are issued within 14 days.", acl=["support", "billing"])
a = fingerprint(doc["text"], doc["acl"], 512, "embed-v3")
b = fingerprint(doc["text"], ["billing", "support"], 512, "embed-v3")
c = fingerprint(doc["text"], ["billing"], 512, "embed-v3")
d = fingerprint(doc["text"], doc["acl"], 256, "embed-v3")
print(a == b, a == c, a == d)      # True False False
Sorting the permission list is not cosmetic: unsorted, the same permissions in a different order look like a change and re-embed the corpus. Chunk size and model are in there because changing either invalidates every stored vector.

Replacing a document means deleting its chunks first#

A document that shrinks from twelve chunks to eight leaves four orphans behind if you only overwrite by chunk id. They are still in the index, still retrievable, and they contain text that was removed from the source, which is the same incident as failing to delete a document, arrived at more subtly. Delete every chunk belonging to the document, then insert the new set.

def replace_document(index, doc_id, chunks, digest, acl):
    index.delete_where(doc_id=doc_id)              # all of them, by document
    for i, chunk in enumerate(chunks):
        index.insert(
            chunk_id=f"{doc_id}:{i}",
            vector=embed(chunk),
            text=chunk,
            doc_id=doc_id,                          # the key deletion needs
            digest=digest,
            acl=acl,
        )
The doc_id on every chunk is what makes deletion possible at all. An index keyed only by chunk id cannot answer the question the sync asks every night.

Deletions#

If a document is deleted or unpublished at the source but its chunks stay in the index, the assistant keeps quoting it. For a withdrawn policy or a document a user lost access to, that is a real incident. Every sync must compare the full set of source ids with the index and remove the difference.

The trap in that set difference is a partial source listing. If the crawl fails halfway and returns two hundred of two thousand documents, the difference is eighteen hundred documents and the sync cheerfully empties the index. Guard it: require the listing to have completed, and refuse to delete more than a small share of the index in one run without a human saying so.

def safe_delete(index_ids, source_ids, listing_complete, max_share=0.05):
    if not listing_complete:
        raise RuntimeError("source listing incomplete, refusing to delete")
    missing = index_ids - source_ids
    if len(missing) > max_share * len(index_ids):
        raise RuntimeError(
            f"{len(missing)} of {len(index_ids)} documents missing, needs review"
        )
    return missing

print(safe_delete({"a", "b", "c", "d"}, {"a", "b", "c"}, True, max_share=0.5))  # {'d'}
try:
    safe_delete({"a", "b", "c", "d"}, {"a"}, True, max_share=0.5)
except RuntimeError as e:
    print(e)                          # 3 of 4 documents missing, needs review
The share is a circuit breaker, not a rule. A genuine bulk deletion should be a decision someone makes, not something a crawler timeout does on your behalf at two in the morning.

When parsing fails#

Some documents will not parse cleanly: scanned pages, broken encodings, huge tables. Do not index garbage. Send them to a quarantine list with the error, alert when it grows, and review it regularly. The reason to alert on the size rather than on each failure is that the interesting signal is a change: three files that never parse are a known annoyance, and three hundred means the source system changed its export format last night.

def sync_one(doc, index, quarantine):
    try:
        chunks = chunk(parse(doc))
    except Exception as e:
        quarantine[doc.id] = f"{type(e).__name__}: {e}"
        return "quarantined"
    if not chunks or sum(len(c) for c in chunks) < 40:
        quarantine[doc.id] = "parsed to nothing, probably a scan"
        return "quarantined"
    replace_document(index, doc.id, chunks, doc.digest, doc.acl)
    return "indexed"
The empty-parse check matters more than the exception handler. A scanned page usually parses successfully into three characters of whitespace, raises nothing, and silently becomes a chunk that matches everything weakly.

Permissions travel with the chunk#

Store who may see each document on every chunk, and filter on it at query time. When permissions change at the source, the sync must update them even if the text did not change, which is why the permission list is inside the fingerprint above rather than beside it.

Filter before the search, not after. Retrieving the top twenty and then discarding the ones this user may not see returns four results for one person and twenty for another, and on a document set where most of it is restricted it returns nothing at all while the index plainly contains the answer. Every serious vector store takes a metadata filter that is applied during the search; use it, and test it with an account that should see almost nothing.

Permission changes are the sync nobody schedules

Text changes when someone edits a document, which is visible and generates an event. Access changes when someone leaves a team, which generates nothing your crawler sees. If the source exposes permissions only on request, you have to re-read them for every document on some schedule regardless of whether the text moved, and that schedule is the real bound on how stale your access control is.

New embedding model#

Changing the embedding model means re-embedding everything into a new index, comparing retrieval quality, and switching over, as with any migration. Store the model name with each vector so a mixed index can never happen silently. A mixed index does not error; it returns nearest neighbours in a space where half the points were placed by a different function, and the results are plausible and wrong.

1  build a second index with the new model, from the same source
2  replay a fixed set of real queries against both
3  compare recall at 10 on the same judgements, and eyeball the disagreements
4  switch reads to the new index, keep the old one for a week
5  delete the old index, once nobody has asked for a rollback
Step 2 is the one that needs to already exist. Without a saved query set with judgements, the comparison becomes somebody trying six searches they can think of and declaring it fine.

Cost it before you agree to it. A million chunks at roughly five hundred tokens each is five hundred million tokens through an embedding model, plus the storage for a second full index while both exist, plus whatever the re-crawl costs at the source. None of that is prohibitive and all of it is a surprise if the first estimate appears after the migration has started.

Where people go wrong

An ingestion job that only ever adds. Stale and withdrawn documents accumulate and the assistant confidently cites them.

Build this: Build a sync over a folder of documents into a local vector store. Test four cases: add, edit, delete and a permission change, plus a corrupted file that must be quarantined.

Open this section on its own page

Where you use this

A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.

RAG with citations you can check
  • Stage 1, Chunks you can point at: Re-ingesting when a document changes, without renumbering everything downstream.

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Build one that survives a rerun

10h

Process a date range into partitioned output, replacing rather than appending. Run it twice on the same date and diff the output. Then backfill a month.

Done when: Two runs on the same date produce byte-identical output, and the backfill used the same code path.

2Catch a broken upstream

10h

Add quality checks including a volume check against expectation. Then feed it a truncated export that is structurally valid but nearly empty, and confirm the pipeline stops rather than writing it.

Done when: The valid-but-tiny batch failed the pipeline instead of silently corrupting downstream data.

20 hours of practice

Interview questions

7 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Moving data reliably

How do you make a data pipeline safe to rerun?

Idempotency: write to a partition keyed by the period being processed and replace it rather than appending, so a rerun produces the same result. Parameterise by date range so backfills use the same code path.

How do you handle an upstream schema change?

Validate incoming data against an expected schema and fail loudly rather than writing whatever arrived. Adding nullable columns is safe; removals and type changes need a versioned migration with both supported during the transition.

What quality checks would you put in a pipeline?

Row count against expectation, uniqueness of keys, null checks on required fields, range checks on numerics, and a volume check. The volume check is what catches an upstream failure that still produces structurally valid but nearly empty data.

Orchestration, streaming and lineage

When would you choose streaming over batch?

Only when freshness changes a decision, such as fraud detection or alerting. Streaming is much harder to operate, so the test is whether anyone would act differently if the data were an hour old.

How do you handle late-arriving data?

Decide explicitly how late an event may be and still be included, using a watermark, and define what happens to events after that. Assuming ordered arrival produces subtly wrong aggregates.

A daily pipeline you can rerun safely

A daily job failed halfway and was restarted. Now some rows appear twice. How do you prevent this?

Make each run idempotent: process a well-defined partition such as one day, write to a temporary location, validate, then replace the whole partition atomically. Reruns then overwrite rather than append, and a failed run publishes nothing.

Then they push further

The target is a database table, not files. Same idea?

Yes: delete and insert the day's rows inside one transaction, or use a merge on a natural key. Either way a rerun leaves the same final state.

Data for a day can keep arriving for a week. How do you handle that?

Reprocess a trailing window, for example the last seven days every run. Idempotent partition replacement makes that safe.

What gets marked up. Marked up for idempotency via partition replacement, atomic publishing, and handling late data by reprocessing a window.

Pipelines for AI: keeping a RAG index in sync

How do you keep a RAG index up to date as documents change?

Run an incremental sync: hash each document's content, and permissions, to skip unchanged ones, re-chunk and re-embed changed ones by replacing all their chunks, delete chunks for documents that disappeared from the source, and quarantine documents that fail to parse. Record the embedding model with each vector.

Then they push further

A user complains the assistant cited a policy withdrawn last month. What failed?

Deletions were not propagated. The sync handled additions and updates but never removed documents missing from the source. Add a set difference between source ids and index ids to every run, and a test for it.

What gets marked up. Strong answers include deletions and permissions without being prompted.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.