BeginnerPipeline Architecture · 25 min · 12 challenges

Ingestion Patterns: Beginner

Black Friday weekend at Shopify. Orders flood the storefront database, a payment partner fires a webhook for every charge, a shipping carrier drops a settlement file at 02:00, and the marketing team wants yesterday's ad spend from a vendor's API before the morning standup. Four sources, 4 ways the data shows up. The ingestion job written for each one decides whether Monday's dashboards are right or quietly short.

The Four Shapes of Ingestion

Daily Life
Interviews
Ingestion is the first role in any pipeline: the step that copies data out of a system you do not control into storage you do. What changes from source to source is how the data arrives, and that decides how the job is written, how it fails, and how you rerun it.

Who moves first, and what arrives

Two questions sort every source: who initiates the transfer, your job or the source, and what unit shows up. The answers give 4 shapes, and each carries its own way of remembering where it stopped.
ShapeWho initiatesUnit that arrivesTypical sourceHow you track progress
Pull from a databaseYour jobRows matching a queryYour company's orders databaseThe largest timestamp you have loaded
Push from a queue or webhookThe sourceOne event at a timeA payment provider sending charge eventsThe offset you last committed
File dropThe source, on its scheduleA whole file or a batch of filesA partner uploading a nightly CSVThe list of files already processed
API pullYour jobA page of records per requestA vendor's web endpointThe cursor of the last page fetched
Sources
Ingestion
Raw
read replica
Orders DB
durable queue
Charge events
object storage bucket
Partner files
HTTPS endpoint
Ads vendor API
nightly batch
Pull job
SLAby 04:00IDEMPOTENCYreplace by order id
always on
Queue consumer
IDEMPOTENCYreplace by event id
runs when sentinel appears
File loader
ERRORblock on count mismatch
hourly
API crawler
RETRY5BACKOFFexponential, honors Retry-After
data lake
Raw tables

The 4 shapes feeding one raw layer. The pull job and the API crawler run on the orchestrator's schedule, the consumer runs continuously, and the file loader waits for the partner to finish.

Picking a shape for a new source

The source makes most of the choice. If you can run SQL against the system, pull from it. If it emits events or offers webhooks, let it push, and each event arrives seconds after it happened. If a partner can only hand you files, you have a file drop. If a vendor exposes a web endpoint and nothing else, you are writing an API job. The real decision comes when a source offers 2 shapes, such as webhooks plus an API: webhooks give freshness, the API fetches what the webhooks missed, and mature setups run both.
Four questions every ingestion job answers
  • ▸Where did I stop last time? The marker in the table's last column.
  • ▸What if the same data arrives twice? Replace by key, so the second landing changes nothing.
  • ▸How do I know the batch is complete? A frozen time window, a sentinel file, or a last page with no cursor.
  • ▸What happens to a record that fails a check? Warn or block, decided before the first run.
TIP
Store the progress marker in the same place you write the data, and move it only after the write succeeds. Every shape in this lesson comes back to that one rule.
Multiple Choice

A marketing vendor holds your campaign spend. It offers a web endpoint returning 100 campaigns per request and, on your enterprise plan, a nightly CSV in a bucket you own. The spend feeds a daily report. Which shape do you start with?

Pull Ingestion from a Database

Daily Life
Interviews
Pull ingestion is a query your job runs against a source database on a schedule. You decide when it runs and how much it asks for, which makes pull the most controllable shape, and asking for too much slows the application that shares the database.

Full load: ask for everything

A full load reads every row and replaces the destination table. For a product_categories table of 3,000 rows this is the right answer: the query takes under a second, there is no marker to maintain, and a row deleted at the source vanishes on the next run. The job empties raw.product_categories, then runs the insert below.

INSERT INTO raw.product_categories
SELECT
category_id,
category_name,
parent_category_id,
updated_at
FROM source.product_categories

Incremental load: ask for what changed

An orders table with 400 million rows cannot be reread nightly. An incremental load asks only for rows changed since the last run. It needs a column that moves whenever a row changes, almost always updated_at, and a stored high-water mark: the largest updated_at the previous run loaded, kept in a small state table next to the raw data.

source_tablehigh_water_marklast_run
orders2026-10-22 03:00:002026-10-22 nightly
customers2026-10-22 03:00:002026-10-22 nightly
refunds2026-10-21 03:00:002026-10-21 nightly (failed on 10-22)
SELECT
order_id,
customer_id,
status,
total_cents,
updated_at
FROM source.orders
WHERE updated_at > TIMESTAMP '2026-10-22 03:00:00'
AND updated_at <= TIMESTAMP '2026-10-23 03:00:00'
ORDER BY updated_at
The upper bound matters. Without it, the query captures rows written while it runs, and the mark you record may not match what you loaded. Freezing both bounds at the start makes the run repeatable: the same 2 timestamps return the same rows, which a rerun after a failure needs.

Write first, then move the mark

def run_incremental(conn, state, run_started_at):
low = state.read_mark("orders")
high = run_started_at
rows = conn.query(ORDERS_SQL, low=low, high=high)
write_replace_by_key(rows, table="raw.orders", key="order_id")
state.write_mark("orders", high)

The order of the last 2 lines is the whole design. If the mark moved first and the write failed, the next run would start above rows that never landed. With the write first, a failure leaves the mark in place, the retry re-pulls the same window, and write_replace_by_key makes the second landing identical to the first. The refunds row above shows this: its 10-22 run failed, so its mark stayed at 10-21 and tonight's run covers 2 days.

Two rows an incremental load misses

The first is the slow transaction. An application opens a transaction at 02:59:40, sets updated_at to that moment, and commits at 03:00:20. Your run's high bound was 03:00:00 and the row was not yet committed, so the query missed it. Tomorrow's run starts above 03:00:00, and the row's 02:59:40 sits below that, so it is skipped forever. The fix is an overlap: pull from the mark minus 10 minutes, and let the replace-by-key write absorb the rows that arrive twice.

The second is the hard delete. A removed row has no updated_at left to move, so no incremental query sees it and the destination keeps a ghost. Until the source flags deletions instead of removing rows, the usual answer is a weekly pull of the primary keys alone, compared against the destination to remove the ghosts.

✓Full load
  • Replaces the whole table each run
  • No progress marker to maintain
  • Picks up deletes for free
  • Cost grows with table size
✗Incremental load
  • Reads only the changed window
  • Needs an updated_at column and a stored mark
  • Blind to hard deletes
  • Cost grows with change volume
Multiple Choice

A nightly job loads raw.orders incrementally in this order: advance the high-water mark to the run start time, run the query, write the rows with replace by order_id. On Tuesday the write failed halfway and the orchestrator's retry on Wednesday succeeded. Which rows are at risk?

Push from Queues and Webhooks

Daily Life
Interviews
In push ingestion the source does the moving. An event happens, the source hands it to a queue or sends it to an endpoint you run, and your consumer sees it seconds later. You gain freshness and give up control: events arrive whether or not your consumer is healthy, in bursts the source decides, and sometimes more than once.

Reading from a queue: the offset

A queue keeps events in order and numbers them. Your consumer reads from a position called the offset, processes what it read, and commits a new offset so a restart resumes from the right place. Where that commit sits relative to the storage write is the question the pull section asked about its mark, and the answer is the same.
Order of operationsCrash between the 2 stepsAfter restart
Commit offset, then write to storageOffset moved, write never happenedEvents lost, and nothing records that they existed
Write to storage, then commit offsetWrite landed, offset did not moveEvents replayed and land a second time

Replay is the one you can live with. The consumer writes each event under its event_id with a replace, so a replayed event overwrites itself and the table ends up identical. Loss leaves a hole only the source can fill, and a source that pushed an event once may have no way to send it again.

def consume(queue, sink, batch_size=500):
while True:
batch = queue.poll(max_records=batch_size)
if not batch:
continue
rows = [to_row(event) for event in batch]
sink.replace_by_key(rows, table="raw.charge_events", key="event_id")
queue.commit(batch[-1].offset + 1)

Webhooks: a push with no queue in it

A webhook is a push over plain HTTP. The vendor sends a POST to a URL you own each time something happens, waits a few seconds for a 200, and retries if it does not get one. The retry schedule is the vendor's: some give up after a few attempts over minutes, others keep trying for days. Either way the vendor keeps no offset for you, so you cannot ask it to resend from a position. Design as if events can be lost, and keep a backfill path such as the vendor's API.

The trap is doing real work inside the endpoint. An endpoint that parses the payload, looks up the customer and writes to the warehouse before replying takes 4 seconds on a normal day and 30 seconds when the warehouse is busy. Against a 10-second timeout, the vendor declares failure on the busy day and retries, and 2 copies of every event pile onto the same slow warehouse. The way out is a queue between the endpoint and the work.
HTTPS POST
Payment vendor
10s timeout
stateless web service
Webhook endpoint
ERRORreply 200 only after enqueue
durable
Event queue
ordered
always on
Consumer
IDEMPOTENCYreplace by event id
raw.charge_events
Raw events

The endpoint verifies the request, enqueues the raw body, and replies in milliseconds. Bursts and duplicates become the consumer's problem, which it already knows how to handle.

What the endpoint does, and nothing more
  • Check the vendor's signature header, so a forged request cannot reach the queue.
  • Write the raw body and a received_at timestamp, which shows how late each event arrived, to the queue unparsed.
  • Return 200. Parsing, validation and warehouse writes happen in the consumer, where a slow night costs lag rather than duplicates.
Multiple Choice

Your webhook endpoint for a shipping vendor parses each payload and appends it straight into raw.shipments. Monitoring shows 3% of shipment events appear twice, and the share rises on nights when the warehouse is busy. What is the most likely cause, and the fix?

File Drop Ingestion

Daily Life
Interviews
A file drop is a push with the slowest rhythm. A partner, a bank or another team writes files to a bucket or shared folder you can read, on a schedule they control. Your job notices the files, checks them, loads them, and remembers that it did. Each step can go wrong, and the fixes are conventions you agree with the partner before the first file lands.

The half-written file

Uploading a 2GB file takes minutes. A job that lists the folder at 02:03 and finds settlements_2026-10-22.csv cannot tell whether the upload finished or whether the file holds the first 1.3GB of 2. Loading it produces a row count that passes any loose check and a daily total short by a third.

Two conventions solve this; ask the partner for at least 1. With write-then-rename, the partner uploads to settlements_2026-10-22.csv.tmp and renames it when the upload ends, and your job ignores anything ending in .tmp. With a sentinel, an empty file named _SUCCESS by convention is written only after every data file in the batch has landed; if it is missing, your job exits and tries again on its next schedule.

The manifest

A sentinel says the batch is complete. A manifest says what complete means: a small file listing every data file in the batch with its row count and a checksum. With a manifest your job can prove it received the batch the partner sent, rather than whatever happened to be in the folder when it looked.
{
"batch_date": "2026-10-22",
"generated_at": "2026-10-23T01:47:12Z",
"files": [
{
"name": "settlements_2026-10-22_part1.csv",
"rows": 184220,
"sha256": "9f2c7a..."
},
{
"name": "settlements_2026-10-22_part2.csv",
"rows": 184301,
"sha256": "41be03..."
}
],
"total_rows": 368521
}
def load_batch(folder, store):
if not folder.exists("_SUCCESS"):
return "waiting"
manifest = folder.read_json("manifest.json")
checksums = [f["sha256"] for f in manifest["files"]]
if store.already_loaded(manifest["batch_date"], checksums):
return "already loaded"
all_rows = []
for entry in manifest["files"]:
name = entry["name"]
if not folder.exists(name):
raise ValueError(f"missing file: {name}")
if folder.sha256(name) != entry["sha256"]:
raise ValueError(f"{name}: checksum does not match manifest")
rows = folder.read_csv(name)
if len(rows) != entry["rows"]:
raise ValueError(f"{name}: expected {entry['rows']} rows, got {len(rows)}")
all_rows.extend(rows)
store.replace_partition("raw.settlements", manifest["batch_date"], all_rows)
store.record_loaded(manifest["batch_date"], checksums)
return "loaded"

A missing file, a checksum that differs from the manifest, or a row count that differs from it raises and blocks the load: the manifest and the data disagree, and loading either version in silence would be wrong. Replacing the whole batch_date partition makes a rerun safe. Recording the checksums means a partner who re-uploads yesterday's batch, sentinel and all, triggers a run that reads the manifest and stops.

Defensive validation

CheckWhat it catchesAction
Sentinel presentA batch still uploadingWait, retry next schedule
Every manifest file exists in the folderA file lost in transferBlock, alert the partner
Row count equals the manifestA truncated or half-written fileBlock
Header matches expected columnsA renamed or reordered columnBlock on rename, warn on an added column
File is not emptyThe partner's export produced nothingBlock
Checksums not seen beforeA re-upload of an old batchSkip and log

The header check is the schema drift lesson applied to files. A CSV carries its column names in line 1. A partner who adds currency at the end has made a backward-compatible change you accept with a warning; one who renames amount to amount_cents has broken the contract, and you stop and talk before loading a row of the new shape.

TIP
Ask for the manifest and the sentinel before the first file arrives. A partner who has shipped 6 months of files without them will resist changing a process that works for them.
Multiple Choice

A bank drops 3 settlement files nightly plus _SUCCESS. On Thursday the sentinel appears and the manifest lists 3 files, but only 2 are in the folder, and the row counts of those 2 match the manifest. What should the loader do?

API Ingestion: The Bug Magnet

Daily Life
Interviews
An API pull looks like a database pull with a web request in place of a query, and the differences are what break it. A vendor's endpoint answers 1 page at a time, against data that changes between pages, behind a limit on how often you may ask, maintained by a team that does not know your job exists.

Pagination: offsets drift, cursors do not

A vendor with 23,000 campaigns returns 100 per request, newest first. Offset pagination asks for offset=0, then offset=100, and so on for 230 requests. A campaign created while you are on page 40 lands on page 1 and pushes every later record down 1 position, so the last record of page 40 becomes the first of page 41 and you load it twice. A deletion above your page shifts records up and you skip 1. With an oldest-first sort, a new campaign lands at the end and repeats nothing.

Cursor pagination returns an opaque token with each page that means continue after this record, so inserts and deletes elsewhere cannot move your position. When a vendor offers both, use the cursor, and store it after each page's write, so a job that dies on page 180 resumes there.
Offset pagination
RequestReturns
?offset=0&limit=100records 1 to 100
?offset=100&limit=100records 101 to 200, shifted if an insert landed
?offset=200&limit=100records 201 to 300, 1 may repeat or be skipped
Cursor pagination
RequestReturns
?limit=100100 records and cursor c1
?limit=100&after=c1the 100 after c1 and cursor c2
?limit=100&after=c2the 100 after c2 and cursor c3

Rate limits: the vendor's 429

Vendors commonly cap requests, say 600 per minute per account. The 601st gets status 429 and often a Retry-After header saying how many seconds to wait. A 429 is a transient failure: retry with exponential backoff, and when the header is present use its number instead of your own. Hammering a vendor that has told you to wait can get API keys suspended.

import time
def fetch_all(client, state):
cursor = state.read_cursor("campaigns")
delay = 1
while True:
response = client.get("/campaigns", params={"limit": 100, "after": cursor})
if response.status == 429:
wait = int(response.headers.get("Retry-After", delay))
time.sleep(wait)
delay = min(delay * 2, 60)
continue
response.raise_for_status()
page = response.json()
if not page.get("ok", True):
raise RuntimeError(f"vendor returned 200 with error: {page.get('error')}")
write_replace_by_key(page["data"], table="raw.campaigns", key="campaign_id")
delay = 1
cursor = page.get("next_cursor")
if cursor is None:
state.write_cursor("campaigns", None)
return
state.write_cursor("campaigns", cursor)

Four things keep the loop honest. The 429 branch sleeps for the vendor's number when present and doubles its own otherwise, capped at 60 seconds. The ok check reads the body, because some vendors return 200 with an error inside. The cursor is written only after the page's rows land, so a crash resumes on the failed page. On the last page the stored cursor is cleared, because it exists to resume one crawl: left in place, it would start the next hourly run at the final page, and the other 22,900 campaigns would never be re-read.

The failure modes vendors create

What vendors change underneath you
  • A page cap: the endpoint returns an empty page for any offset of 10,000 or more, so a 23,000-record pull stops at record 10,000 with no error.
  • A field that turns nullable: budget_cents was always present, then 1 campaign returns null and the integer cast fails on page 77.
  • A timeout on page 230 with no cursor stored, so the retry re-crawls from page 1 and spends the rate limit on pages it already loaded.
✓Do
  • Pull by updated_since when offered, with a few minutes of overlap
  • Honor Retry-After before applying your own backoff
✗Don't
  • Retry a 401 or 403: the credential is wrong and waiting cannot fix it
  • Parse timestamps without confirming the vendor's timezone in writing

Practice: the drifting count

Multiple Choice

Your hourly campaign pull uses offset pagination over a newest-first list and appends rows to raw.campaigns. Row counts run 2 to 5 higher than the vendor's dashboard most hours, never lower. What explains it?

Capstone: the webhook outage

Multiple Choice

A payments vendor offers webhooks and an events API that returns the same events under the same event_id, with cursor pagination and an updated_since filter. Your webhook endpoint, which enqueues to a queue, was down for 40 minutes on Monday from 14:00, and the vendor's retries for that window are exhausted. Which approach restores Monday's data?

Capstone: 4 sources, 4 markers

Multiple Choice

Four sources feed a new raw layer: the orders database, a payment event queue, a bank's nightly files, and an ads vendor's cursor-paginated API. Which set of progress markers is right for all 4?

❯❯❯PUTTING IT ALL TOGETHER

> A ride-hailing company launches in a new country. Trips come from its own trips database on a read replica, driver payouts arrive as webhooks from a local payments provider, the tax authority receives a nightly fares file and drops a nightly file of adjustments back into a shared bucket, and the mapping vendor exposes an hourly API of road closures. One raw layer, all 4 shapes, and a launch-day dashboard that has to be right by 07:00 on the first morning.

The trips pull uses a high-water mark on updated_at with a 10-minute overlap, writing with replace by trip_id, so a failed night is rerun by leaving the mark where it was.
The payout webhooks go through an endpoint that only verifies and enqueues; the consumer behind the queue writes with replace by event_id and commits its offset last, and the provider's API with updated_since backfills any outage.
The adjustments file is loaded only when _SUCCESS is present, checked row by row against the manifest, blocked on a count mismatch, and replaced as one partition per batch date.
The road closures pull follows the cursor, stores it after each page lands, sleeps for the vendor's Retry-After on a 429, and reads the body for an error before trusting a 200.
All 4 jobs answer the same questions from the first section: where did I stop, what if it arrives twice, how do I know it is complete, and what do I do with a record that fails a check.
KEY TAKEAWAYS
Ingestion comes in 4 shapes, sorted by who initiates and what unit arrives: database pull, queue or webhook push, file drop, and API pull.
Every shape stores a progress marker, and the marker moves only after the write succeeds: high-water mark, committed offset, loaded checksums, cursor.
Duplicates are the price of a safe progress marker. Replace by key absorbs them; append keeps every copy.
Completeness needs a signal the data cannot give by itself: a frozen upper bound, a sentinel plus a manifest, or a page with no next cursor.
A webhook endpoint verifies, enqueues and replies. The parsing and writing belong to a consumer behind a queue.
Cursors beat offsets, Retry-After beats your own backoff, and a 200 whose body says error is still a failure.

Four ways data arrives, and the progress marker, duplicate rule and completeness check each one needs.

Category
Pipeline Architecture
Difficulty
beginner
Duration
25 minutes
Challenges
12 hands-on challenges

Topics covered: The Four Shapes of Ingestion, Pull Ingestion from a Database, Push from Queues and Webhooks, File Drop Ingestion, API Ingestion: The Bug Magnet

Lesson Sections

  1. The Four Shapes of Ingestion

    Ingestion is the first role in any pipeline: the step that copies data out of a system you do not control into storage you do. What changes from source to source is how the data arrives, and that decides how the job is written, how it fails, and how you rerun it. Who moves first, and what arrives Two questions sort every source: who initiates the transfer, your job or the source, and what unit shows up. The answers give 4 shapes, and each carries its own way of remembering where it stopped. Pick

  2. Pull Ingestion from a Database

    Pull ingestion is a query your job runs against a source database on a schedule. You decide when it runs and how much it asks for, which makes pull the most controllable shape, and asking for too much slows the application that shares the database. Full load: ask for everything Incremental load: ask for what changed The upper bound matters. Without it, the query captures rows written while it runs, and the mark you record may not match what you loaded. Freezing both bounds at the start makes the

  3. Push from Queues and Webhooks

    In push ingestion the source does the moving. An event happens, the source hands it to a queue or sends it to an endpoint you run, and your consumer sees it seconds later. You gain freshness and give up control: events arrive whether or not your consumer is healthy, in bursts the source decides, and sometimes more than once. Reading from a queue: the offset A queue keeps events in order and numbers them. Your consumer reads from a position called the offset, processes what it read, and commits a

  4. File Drop Ingestion

    A file drop is a push with the slowest rhythm. A partner, a bank or another team writes files to a bucket or shared folder you can read, on a schedule they control. Your job notices the files, checks them, loads them, and remembers that it did. Each step can go wrong, and the fixes are conventions you agree with the partner before the first file lands. The half-written file The manifest A sentinel says the batch is complete. A manifest says what complete means: a small file listing every data fi

  5. API Ingestion: The Bug Magnet

    An API pull looks like a database pull with a web request in place of a query, and the differences are what break it. A vendor's endpoint answers 1 page at a time, against data that changes between pages, behind a limit on how often you may ask, maintained by a team that does not know your job exists. Pagination: offsets drift, cursors do not Cursor pagination returns an opaque token with each page that means continue after this record, so inserts and deletes elsewhere cannot move your position.