Pagination, rate limits, watermarks and high-water marks, incremental keys, and auth, so extraction never silently drops rows.
⏱ 20 min readTopics chapter readerLevel · Building & Orchestration
01 · Overview
Start here
⏱ 6 min · Topic 1 of 11
What you will learn
The three promises every extractor makes.
Pages that do not skip or repeat rows when new rows arrive.
A watermark that survives a tie at the boundary.
Rate limits, Retry-After, and backoff with jitter.
Which errors to retry, and how to refresh a token.
How to split a big read into safe parts.
Extraction is the step that reads data out of a source and into your pipeline. You met it in Data Sources & Ingestion Patterns (chapter 05). This chapter goes deeper.
Here is a quick recap of the words you need. A watermark is the saved value that marks how far the last run read. A cursor is a bookmark the API gives you.
Status code 429 means too many requests. A header is an extra line of information that comes with an answer. A Retry-After header says how long to wait.
From Change Data Capture (CDC) (chapter 06) you know that a timestamp watermark misses deletes. This chapter keeps the watermark and makes it safe. For deletes, use CDC.
One fact explains the whole chapter. If extraction misses a row, the next steps never know that the row existed. So extraction must be right.
02 · The promises
What extraction really has to guarantee
⏱ 7 min · Topic 2 of 11
Every extractor makes three promises. If it breaks one, the data is wrong, and often nobody sees it.
Complete: every row that should arrive does arrive.
Safe repeats: a row that arrives twice counts only once.
Resumable: after a crash, the job continues where it stopped.
The three work together. To resume, the job re-reads a little, and that can repeat rows. So repeats must be harmless.
A checkpoint is a saved position, such as a cursor or a watermark. The job saves it after each page it has written. This is called at-least-once: no row is lost, and a few may repeat.
Idempotency, Retries & Exactly-Once (chapter 11) covers the safe write in full. Here, watch the checkpoint work. Each page holds three orders, and the cursor is the last order_id read.
Run 1 wrote 101 to 103, saved cursor 103, then crashed before it wrote page 2. Run 2 started after 103 and wrote 104 to 106. Six rows, none twice.
The helpers load_checkpoint, fetch_page, write_to_landing and save_checkpoint are made up. The landing table is the raw copy of what arrived.
A write by key means update or insert by order_id. Chapter 05 did this in its orders load.
On the last page there is no new checkpoint, so every later run re-reads that page. The write by key makes that harmless.
Watch out
Write first, then save the checkpoint. A crash between the two repeats the page, which a write by key makes harmless. Saving first would lose the page for ever.
03 · Pages
Pagination done right
⏱ 10 min · Topic 3 of 11
An API returns rows in pages. A page is a small group of rows, such as two payments. You ask again until the API says it is done.
There are two ways to ask for the next page. Offset says "skip N rows, then give me M rows". A keyset cursor says "give me the rows after this key".
The API calls in this chapter start with GET. GET is a web call that only asks for data.
Offset counts rows, so it breaks when rows change while you read. Here the job asks for the newest payment first, two per page. Payment p_9004 is made up and arrives between call 1 and call 2.
Offset paging while a payment arrivesworked example
SQL
Input data
payments at call 1 (newest first)3 rows
payment_id
order_id
amount
p_9003
104
15.50
p_9002
102
25.00
p_9001
101
40.00
payments at call 2 (newest first)4 rows
payment_id
order_id
amount
p_9004
105
22.00
p_9003
104
15.50
p_9002
102
25.00
p_9001
101
40.00
p_9004 is the new first row, so every other row moved down one place.
p_9002 came twice, and p_9004 was never read in this run. Nothing raised an error.
The order parameter is made up.
Deletes cause the opposite. Say instead that no payment arrives, and p_9003 is deleted after call 1. The list is now p_9002, p_9001, so call 2 (offset 2) gets nothing and p_9001 is never read.
Deep offsets are also usually slower on big tables. The database must walk past every skipped row.
A keyset cursor does not count rows. It remembers the last key, and the next call asks for keys greater than it. This works because each new payment gets a larger payment_id.
In chapter 05 the API gave a cursor you cannot read, such as c2. To show the idea, this cursor is the plain payment_id.
Here are the same payments, oldest first. Again p_9004 arrives between call 1 and call 2.
Cursor paging while a payment arrivesworked example
Every payment came once, including the new p_9004. Call 3 has no rows and no next_cursor. That is the stop signal.
Pass a real cursor back unchanged. Do not build or edit it yourself.
If a payment with a smaller id saves late, the keyset can still miss it. The next section shows the same problem with timestamps.
The key must be unique. If you sort by updated_at, many rows can share one value. Sort by updated_at, then by payment_id, so the cursor points to one place.
Watch out
Stop only when the API says so: next_cursor is null, or the page is empty. A loop that stops after 1 page reads p_9001 and p_9002 only. The job still shows success.
04 · Incremental
Watermarks & incremental keys
⏱ 12 min · Topic 4 of 11
An incremental load reads only rows that are new or changed. Its watermark is the largest value of one column that you have loaded. That column is the incremental key.
Pick a key that changes whenever the row changes. Here are three choices for the orders table.
Three incremental keys
Key
It catches
It misses
order_id
New orders.
A change to an old order, such as order 105 being paid.
order_date
New orders.
Order 105. Its date stays 2026-01-20 when it is paid.
updated_at
New orders and changes.
Deletes, and edits that do not move updated_at.
updated_at is the usual choice. A bulk fix or a bug can still change rows without moving it. The watermark never sees those rows.
Now a trap: ties. Many rows can carry the same updated_at. A transaction is a group of changes that are saved together.
Here the app sets updated_at with now(). In PostgreSQL, now() gives the time the transaction started, not the time it ended. Other readers see the row only when the transaction ends.
Orders 105 and 189 share the stamp 09:00:00 on 2026-01-25. Order 189 is made up.
The stamps are rounded to seconds. Follow the timeline.
The timeline on 2026-01-25
Time
What happens
09:00:00
Order 105 is paid. Its updated_at is 09:00:00.
09:00:00
Order 189 starts to save with the same stamp. It is not saved yet.
09:00:05
Run A reads. It sees only order 105. The watermark becomes 09:00:00.
09:00:07
Order 189 finishes saving. Its updated_at is still 09:00:00.
10:00:00
Run B reads.
First, run B with a strict > filter.
Run B with >worked example
SQL
Input data
orders (source) with updated_at on 2026-01-25, at 10:002 rows
order_id
customer_id
status
amount
updated_at
105
3
paid
22.00
2026-01-25 09:00:00
189
5
paid
18.00
2026-01-25 09:00:00
Order 189 is made up and used only in this chapter. Its updated_at is the same as order 105.
SELECTorder_id,customer_id,status,amount,updated_atFROMordersWHEREupdated_at>'2026-01-25 09:00:00'-- the saved watermark
Result
rows read
0
Order 189 is lost. Its stamp equals the watermark, and is not greater. Later runs never go back, and nothing shows an error.
Now run B with >=. It reads the boundary row again, so order 105 comes twice.
Run B with >=worked example
SQL
Input data
orders (source) with updated_at on 2026-01-25, at 10:002 rows
order_id
customer_id
status
amount
updated_at
105
3
paid
22.00
2026-01-25 09:00:00
189
5
paid
18.00
2026-01-25 09:00:00
Order 189 is made up and used only in this chapter. Its updated_at is the same as order 105.
Three rows became two. If copies can differ, keep the newest per order_id. Transformation Fundamentals (chapter 08) shows how.
The >= filter fixes only a tie exactly at the watermark. A row whose stamp is older than the watermark, but that saves after the read, is still missed. A lookback re-reads a few days to catch it.
Incremental Processing & Backfills (chapter 12) covers the lookback and the half-open window.
Watch out
Save the largest updated_at you read, not the time the job ran. If run A had saved 09:00:05, even >= would lose order 189. Move the watermark only after the write works.
Interview question
What can go wrong when a load uses updated_at > watermark? Ties and late saves lose rows. Fix it with >=, a write by key and a lookback.
05 · Limits
Rate limits & backoff
⏱ 9 min · Topic 5 of 11
A rate limit caps how many calls you may make in a period. The API checks it. When you go over, it answers 429.
The answer may carry a Retry-After header. It holds a number of seconds, or a date. Both mean "wait this long".
This code turns either form into seconds. The date form is an HTTP date, in UTC (the world standard time). It ends with the letters GMT.
What if there is no Retry-After? Do not retry at once. Many fast retries make the problem worse.
Exponential backoff means each wait limit is larger than the last. Here the limit doubles up to a cap. Jitter means you pick a random time inside the limit.
Jitter matters when many workers fail together. A worker is one copy of the job that runs at the same time as others.
Without jitter, all workers retry at the same moments and hit the API together again. The AWS Architecture Blog post "Exponential Backoff And Jitter" describes this and recommends jitter.
The three draws (0.6, 1.3, 2.5) are made-up random numbers. The call times are the running total: 0.6, then 1.9, then 4.4. Each call is counted as taking no time.
Only 429 is handled here. The next section says which other errors to retry. With 5 tries, the loop never waits after try 5.
Watch out
Always cap the tries. After the last try, fail loudly so a person sees it. Retrying for ever hides a real outage.
06 · Errors
Errors and expired tokens
⏱ 7 min · Topic 6 of 11
Not every error deserves a retry. A retry helps when the problem is short. It does not help when the request itself is wrong.
A call that only reads, such as GET, is safe to repeat. This table says what the job does for each answer.
Which answers to retry
Answer
What it means
What the job does
429
Too many requests.
Wait, then retry.
500, 502, 503, 504
The server failed. It is often brief.
Retry with backoff, a few times.
Timeout or dropped connection
No answer in time.
Retry with backoff, a few times.
400 or 404
Your request is wrong, or the item is missing.
Do not retry. Fix the code or the address.
401
No valid login. A token may have expired.
Refresh the token once, then retry once.
403
Your login is valid, but you are not allowed.
Do not retry. Ask the owner for access.
Watch out
A few APIs send 403 for rate limits. Read the API’s documents.
Many APIs use OAuth. OAuth is a common standard for giving a program access to an API. Your job sends an access token with each call.
A token is a secret string that proves who you are. It expires after a short time, and each API chooses how long. Bearer is the word OAuth puts before the token in the header.
The job also holds a longer-lived secret, such as a refresh token. It trades that for a new access token. The details differ by API, so read its documents.
A long run can outlive its token. Here the token expires at page 41 of 90. The job refreshes it and keeps its cursor.
The run does not restart. The cursor keeps its place, and the tokens T1 and T2 are made up.
07 · Scale
Parallelism & partitioning
⏱ 8 min · Topic 7 of 11
One worker reading a huge table may not finish in time. Parallel extraction splits the table into parts. Each part goes to its own worker, and the parts run at the same time.
A partition is one such part. Cut by a key that never changes for a row, such as order_date or order_id. Use half-open ranges, which include the start and exclude the end.
Incremental Processing & Backfills (chapter 12) uses the same idea for time windows.
Here are the six orders, cut into three date ranges. One worker reads each range.
Six rows in total, each order once. Order 103 (01-11) is only in part 2. Order 106 (01-21) is only in part 3.
Now the same split with BETWEEN, which includes both ends. Orders on the boundary days fall into two parts.
BETWEEN reads boundaries twiceworked example
SQL
Input data
orders (source)6 rows
order_id
order_date
101
2026-01-03
102
2026-01-10
103
2026-01-11
104
2026-01-15
105
2026-01-20
106
2026-01-21
-- worker 1: BETWEEN '2026-01-01' AND '2026-01-11'-- worker 2: BETWEEN '2026-01-11' AND '2026-01-21'-- worker 3: BETWEEN '2026-01-21' AND '2026-01-31'
What each worker reads
part
orders read
rows
1
101, 102, 103
3
2
103, 104, 105, 106
4
3
106
1
Eight rows for six orders. Orders 103 and 106 are read twice.
Parts also help recovery. Each part has its own checkpoint. If part 2 fails, you run only part 2 again, which reads 3 orders.
Replace the whole part when you load it. Then a half-written part leaves no repeats. Idempotency, Retries & Exactly-Once (chapter 11) covers this.
Parts can be uneven. A busy day can hold far more rows than a quiet day, and an id range with gaps can hold few rows.
Watch out
More workers are not always faster. Each worker is one more connection to a database, or one more caller of an API. Many APIs count calls for all your workers together, so ask the owner how many workers you may use.
08 · In code
Four extractors, laptop and production
⏱ 12 min · Topic 8 of 11
A laptop version works on a small test. A production version survives big data and failures. Each extractor below has both versions.
Laptop and production, side by side
Source
Laptop version
Production version
PostgreSQL orders
Read all rows into one big list.
Read a few rows at a time.
Payment API
No timeout. Fail on the first error.
Set timeouts. Retry the errors that deserve it.
A table with no updated_at
Reload everything every night.
Compare with the last copy. Load only the differences.
Passwords
Type them in the code.
Fetch them when the job runs.
Extractor 1: the database
In a database, a cursor is a different thing from an API cursor. It is a query result that stays on the database server, and you fetch it a few rows at a time.
One big list works for six orders. It fails on millions, because memory fills.
psycopg2 is a Python library for PostgreSQL. A library is ready-made code you import. A batch is a small group of rows, such as two orders.
A named cursor keeps the result on the database server, and you fetch one batch at a time.
Memory holds 2 rows at a time, not 6. The query names its columns, so a new column in the source cannot widen the read.
Extractor 2: the API
requests is a Python library for web calls. It has no default timeout, so a stuck call can wait for ever. The scheduler still shows the job as running.
PAYMENTS_URL is made up. The backoff loop from Rate limits & backoff and the token refresh from Errors and expired tokens go around this call.
Extractor 3: no updated_at
Some tables have no updated_at. Load a full copy into a staging table, then compare it with the warehouse. A staging table holds today’s full copy for a short time.
IS DISTINCT FROM treats NULL as a value.
Find new and changed rowsworked example
SQL
Input data
staging_orders (today)7 rows
order_id
status
amount
101
paid
40.00
102
paid
25.00
103
refunded
60.00
104
paid
15.50
105
paid
22.00
106
paid
80.00
189
paid
18.00
Order 189 is made up. Order 105 was paid after the last copy.
dw_orders (last copy)6 rows
order_id
status
amount
101
paid
40.00
102
paid
25.00
103
refunded
60.00
104
paid
15.50
105
pending
NULL
106
paid
80.00
SELECTt.order_id,t.status,t.amountFROMstaging_orderstLEFTJOINdw_orderswONw.order_id=t.order_idWHEREw.order_idISNULL-- new rowORw.statusISDISTINCTFROMt.status-- changedORw.amountISDISTINCTFROMt.amount-- changedORDERBYt.order_id;
Rows to load
order_id
status
amount
105
paid
22.00
189
paid
18.00
Order 105 changed and order 189 is new. With <> instead, a change from NULL to 22.00 would be missed if only the amount changed.
The same compare, turned around, finds rows that were deleted. On wide tables, some teams compare one hash of the whole row, a short code made from all its values.
Extractor 4: passwords and secrets
A password in code stays in the history of your code (git), and anyone who reads the code sees it. A secret store is a safe place that keeps passwords. Fetch them from there when the job runs.
boto3 is the Python library for AWS (Amazon Web Services). The secret name here is made up.
AWS Secrets Manager is one choice. Other clouds have similar services. When the team changes the secret, the job needs no new code.
09 · Recap
Recap
⏱ 3 min · Topic 9 of 11
Extraction must be complete, safe to repeat, and resumable. Write first, then save the checkpoint.
Offset paging breaks when rows change. A keyset cursor does not skip or repeat rows when rows are added or removed, except rows that save late. Stop when the API says stop.
With a tie at the watermark, > loses rows. Use >= and remove the repeats.
Wait for Retry-After, or use backoff with jitter. Cap the tries. Retry only short errors, such as 429 and 503.
Cut big reads into half-open parts. Retry one part at a time, and ask the owner how many workers you may use.
Check yourself
Without looking: why does > lose order 189 but >= keep it?
10 · Practice
Practice lab
⏱ 3 min · Topic 10 of 11
Five scenarios. Build each one on the pipeline canvas with the chapter closed. The review tells you which requirement your design missed.