Guides / API export completeness

Convert Paginated API JSON to CSV Without Missing Later Pages

Collect every response in the documented pagination chain before using a JSON to CSV converter. Follow the next link until the API indicates the end; save each page's URL, status and record count, then check stable record IDs for duplicates. A CSV made from the first response contains only that response's records.

1 · Capture pages2 · Audit the chain3 · Export CSV

For GitHub REST pagination, follow rel="next" in the response Link header; do not invent the next URL or assume a last link always exists. See GitHub's pagination documentation. Other APIs use cursors or continuation tokens: apply that API's documented end condition.

Three saved pages have 2, 2 and 1 records, yielding five unique IDs and five CSV rows after a terminal page.
Our synthetic example: 3 saved pages, 5 received records, 5 unique IDs and 5 CSV data rows. These are locally measured fixture counts.

What to save with each response

Save the response body and its pagination metadata together. The downloadable fixture uses a normalized capture format: url, status, next_url and records. It is not a raw API response. A collector must obtain these values from real requests, select the correct records array, and parse the API's pagination headers or tokens. The auditor below makes no network requests.

PageIDs receivedNext pageNew field
1R01, R022id, name
2R03, R043region
3R05nonenone

Page 2 introduces region. Build the column union over the collected records so that the first page does not define an incomplete header. Keep missing region cells empty according to this example's explicit projection.

Reproduce the original example
Three-page fixturePython auditorMeasured CSVMeasured manifest

Run the chain audit before exporting

python audit-pagination.py pagination-sample.json run-001

Use a new output directory. The script audits the whole captured chain before creating CSV. It refuses a missing next page, a repeated page URL, a non-200 response, an unreachable saved page or a duplicate string ID. This fixture contract expects arrays of objects with string IDs; adapt it deliberately when the API has a different response or key. APIs without stable IDs need a documented composite key or another comparison method.

Read the complete offline auditor
"""Audit saved pagination responses; no network requests. Python 3 standard library.
Input is normalized capture metadata, not raw HTTP or an API response.
Run: python audit-pagination.py pagination-sample.json new-output-directory
"""
import csv, json, sys
from pathlib import Path
bundle = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
pages = {}
for page in bundle["pages"]:
    if page["url"] in pages: raise ValueError("duplicate saved page URL")
    pages[page["url"]] = page
url = bundle["start_url"]
visited, ids, rows, ledger, columns = set(), set(), [], [], []
while url is not None:
    if url in visited: raise ValueError("pagination loop: " + url)
    if url not in pages: raise ValueError("missing saved next page: " + url)
    page = pages[url]
    if page["status"] != 200: raise ValueError("unsuccessful page: " + url)
    records = page["records"]
    if not isinstance(records, list): raise ValueError("records must be an array")
    for row in records:
        if not isinstance(row, dict) or not isinstance(row.get("id"), str):
            raise ValueError("every record needs a string id")
        if row["id"] in ids: raise ValueError("duplicate record id: " + row["id"])
        ids.add(row["id"])
        for key in row:
            if key not in columns: columns.append(key)
        rows.append(row)
    ledger.append({"url":url, "records":len(records), "next_url":page["next_url"]})
    visited.add(url)
    url = page["next_url"]
if visited != set(pages): raise ValueError("unreachable saved pages")
out = Path(sys.argv[2]); out.mkdir(exist_ok=False)
def cell(value):
    if value is None: return ""
    if isinstance(value, (dict, list, bool)): return json.dumps(value, ensure_ascii=False)
    return str(value)
with (out/"records.csv").open("w",encoding="utf-8",newline="") as handle:
    writer=csv.writer(handle); writer.writerow(columns)
    writer.writerows([[cell(row.get(key)) for key in columns] for row in rows])
report={"pages":len(ledger),"received_records":len(rows),"unique_ids":len(ids),
        "columns":columns,"ended_without_next":True,"page_ledger":ledger,
        "scope":"saved chain only; not a snapshot or permission completeness guarantee"}
(out/"manifest.json").write_text(json.dumps(report,indent=2)+"\n",encoding="utf-8")
print(json.dumps(report))

Check the measured results

The clean fixture produces 5 data records and 3 columns, with header id,name,region. The page ledger records 2 + 2 + 1 = 5 received records. The unique-ID set also contains five values: R01 through R05. These counts were obtained by running the downloadable script on the downloadable fixture.

In a deliberate failure case, changing page 2's R03 to R02 produces ValueError: duplicate record id: R02. The auditor stops before export. Do not silently drop repeated IDs: an overlap may hide changed records or an unstable pagination order. Investigate and rerun a fresh capture.

Read CSV with a CSV parser to count records, not by counting text lines. Quoted fields can contain newlines. Python's CSV documentation explains the reader and newline='' handling used here.

Question: How do I know every API page reached my CSV?

Check that every saved next pointer leads to a successful saved response, the final response has no next pointer under that API's contract, and received IDs match exported IDs. In this fixture, the manifest establishes a three-page chain and the CSV has the same five IDs. The first page alone would have only R01 and R02. Raising page size does not by itself prove that all records fit.

Completeness has a scope. A terminal page and unique IDs prove properties of the captured chain. They do not prove that your account could access every upstream record, that no records changed during pagination, or that the server returned a consistent snapshot.

For a live export, record the limits too

Open the JSON to CSV converter · Verify exported fields · Preserve field states

See the workflow