Ingestion Patterns: Beginner
Who moves first, and what arrives
| Shape | Who initiates | Unit that arrives | Typical source | How you track progress |
|---|---|---|---|---|
| Pull from a database | Your job | Rows matching a query | Your company's orders database | The largest timestamp you have loaded |
| Push from a queue or webhook | The source | One event at a time | A payment provider sending charge events | The offset you last committed |
| File drop | The source, on its schedule | A whole file or a batch of files | A partner uploading a nightly CSV | The list of files already processed |
| API pull | Your job | A page of records per request | A vendor's web endpoint | The cursor of the last page fetched |
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
- ▸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.
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?
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.
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_table | high_water_mark | last_run |
|---|---|---|
| orders | 2026-10-22 03:00:00 | 2026-10-22 nightly |
| customers | 2026-10-22 03:00:00 | 2026-10-22 nightly |
| refunds | 2026-10-21 03:00:00 | 2026-10-21 nightly (failed on 10-22) |
Write first, then move the mark
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.
- Replaces the whole table each run
- No progress marker to maintain
- Picks up deletes for free
- Cost grows with table size
- Reads only the changed window
- Needs an updated_at column and a stored mark
- Blind to hard deletes
- Cost grows with change volume
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?
Reading from a queue: the offset
| Order of operations | Crash between the 2 steps | After restart |
|---|---|---|
| Commit offset, then write to storage | Offset moved, write never happened | Events lost, and nothing records that they existed |
| Write to storage, then commit offset | Write landed, offset did not move | Events 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.
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 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.
- 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.
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?
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 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
| Check | What it catches | Action |
|---|---|---|
| Sentinel present | A batch still uploading | Wait, retry next schedule |
| Every manifest file exists in the folder | A file lost in transfer | Block, alert the partner |
| Row count equals the manifest | A truncated or half-written file | Block |
| Header matches expected columns | A renamed or reordered column | Block on rename, warn on an added column |
| File is not empty | The partner's export produced nothing | Block |
| Checksums not seen before | A re-upload of an old batch | Skip 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.
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?
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.
| Request | Returns |
|---|---|
| ?offset=0&limit=100 | records 1 to 100 |
| ?offset=100&limit=100 | records 101 to 200, shifted if an insert landed |
| ?offset=200&limit=100 | records 201 to 300, 1 may repeat or be skipped |
| Request | Returns |
|---|---|
| ?limit=100 | 100 records and cursor c1 |
| ?limit=100&after=c1 | the 100 after c1 and cursor c2 |
| ?limit=100&after=c2 | the 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.
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
- 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.
- Pull by updated_since when offered, with a few minutes of overlap
- Honor Retry-After before applying your own backoff
- 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
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
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
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?
> 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.
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.event_id and commits its offset last, and the provider's API with updated_since backfills any outage._SUCCESS is present, checked row by row against the manifest, blocked on a count mismatch, and replaced as one partition per batch date.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
- 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
- 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
- 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
- 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
- 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.