---
# === IDENTITY ===
id: business/erp-integration/pagination-patterns-comparison/2026
canonical_question: "How do pagination patterns differ across ERPs - cursor vs offset vs keyset vs nextRecordsUrl?"
aliases:
  - "ERP API pagination comparison cursor offset keyset"
  - "Salesforce nextRecordsUrl vs SAP $skip vs Oracle offset pagination"
  - "Which ERP pagination method scales best for large datasets?"
  - "Deep pagination performance across ERP REST APIs"
entity_type: erp_integration
domain: business > erp-integration > pagination-patterns-comparison
region: global
jurisdiction: global
temporal_scope: 2025-2026

# === SYSTEM PROFILE ===
systems:
  - name: "Salesforce"
    vendor: "Salesforce"
    version: "API v62.0 (Spring '26)"
    edition: "Enterprise, Unlimited, Developer"
    deployment: cloud
    api_surface: "REST (nextRecordsUrl / query locator)"
  - name: "SAP S/4HANA Cloud"
    vendor: "SAP"
    version: "2408 / 2502"
    edition: "Public Cloud, Private Cloud"
    deployment: cloud
    api_surface: "OData V2/V4 ($skip/$top, $skiptoken, server-driven paging)"
  - name: "Oracle ERP Cloud (Fusion)"
    vendor: "Oracle"
    version: "24D / 25A"
    edition: "Standard, Enterprise"
    deployment: cloud
    api_surface: "REST (offset/limit)"
  - name: "Oracle NetSuite"
    vendor: "Oracle"
    version: "2025.1"
    edition: "Standard, Premium, Enterprise, Ultimate"
    deployment: cloud
    api_surface: "REST (offset/limit), SuiteQL (offset/limit)"
  - name: "Microsoft Dynamics 365 F&O"
    vendor: "Microsoft"
    version: "10.0.40+"
    edition: "Finance, Supply Chain Management"
    deployment: cloud
    api_surface: "OData V4 (@odata.nextLink, $skip/$top, $skiptoken)"
  - name: "Workday"
    vendor: "Workday"
    version: "2025R1"
    edition: "All editions"
    deployment: cloud
    api_surface: "REST (page/offset), SOAP (page-based), RaaS (no native pagination)"
  - name: "IFS Cloud"
    vendor: "IFS"
    version: "24R2"
    edition: "All editions"
    deployment: cloud
    api_surface: "OData V4 ($skip/$top, server-driven paging)"
  - name: "Acumatica"
    vendor: "Acumatica"
    version: "2024 R2"
    edition: "All editions"
    deployment: cloud
    api_surface: "REST ($top/$skip, filter-based keyset)"

# === VERIFICATION ===
last_verified: 2026-03-03
confidence: 0.86
version: 1.0
first_published: 2026-03-03

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: "Salesforce v62.0 added GraphQL cursor pagination; D365 adopted $skiptoken for server-driven paging"
  next_review: 2026-08-30
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "Salesforce: Default page size 2,000 records for REST SOQL queries — server-controlled, not adjustable"
  - "SAP S/4HANA: $skip/$top client-side paging can cause duplicate/missing records under concurrent writes — use server-driven $skiptoken instead"
  - "Oracle ERP Cloud: Default offset/limit capped at 500 records per page — results not ordered by default when using offset"
  - "NetSuite: Maximum 1,000 pages of results regardless of page size — caps total retrievable records at pageSize x 1,000"
  - "Dynamics 365 F&O: maxpagesize preference capped at 10,000 per page — server may return fewer"
  - "Workday RaaS: No native pagination — reports over 50K rows must use WQL API or pseudo-pagination with prompt parameters"
  - "Acumatica: $skip-based pagination has no server-enforced page limit but degrades linearly with offset"
  - "All platforms: Offset-based pagination degrades at >100K records — O(n) database scan required to skip rows"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs general REST API capability comparison across ERPs"
    use_instead: "business/erp-integration/erp-rest-api-comparison/2026"
  - condition: "User needs rate limits comparison (not pagination)"
    use_instead: "business/erp-integration/erp-rate-limits-comparison/2026"
  - condition: "User needs bulk data import/export patterns"
    use_instead: "business/erp-integration/erp-bulk-import-comparison/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: data_volume
    question: "How many records do you need to paginate through?"
    type: choice
    options:
      - "< 10,000 records (offset is fine)"
      - "10,000-100,000 records (cursor preferred)"
      - "> 100,000 records (cursor or keyset required)"
      - "> 1,000,000 records (bulk API recommended)"
  - key: consistency_requirement
    question: "Can the underlying data change while you paginate?"
    type: choice
    options:
      - "Static dataset (no concurrent writes)"
      - "Slowly changing (occasional updates)"
      - "Highly volatile (frequent inserts/deletes during pagination)"
  - key: navigation_pattern
    question: "Do you need random page access or sequential-only?"
    type: choice
    options:
      - "Sequential (next page only) — cursor/keyset is ideal"
      - "Random access (jump to page N) — offset required"
      - "Resume from checkpoint — cursor with bookmark"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/business/erp-integration/pagination-patterns-comparison/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-03-03)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: "business/erp-integration/erp-rest-api-comparison/2026"
      label: "REST API capabilities comparison across ERPs"
    - id: "business/erp-integration/erp-rate-limits-comparison/2026"
      label: "Rate limits comparison across ERPs"
    - id: "business/erp-integration/erp-bulk-import-comparison/2026"
      label: "Bulk import comparison across ERPs"
  solves:
    - id: "business/erp-integration/change-data-capture-erp/2026"
      label: "CDC patterns for incremental data sync (alternative to full pagination)"
  alternative_to:
    - id: "business/erp-integration/erp-bulk-import-comparison/2026"
      label: "Bulk import comparison — use when data volume exceeds pagination feasibility"
  often_confused_with:
    - id: "business/erp-integration/batch-vs-realtime-integration/2026"
      label: "Batch vs real-time — pagination is a mechanism, not a pattern"

# === SOURCES ===
sources:
  - id: src1
    title: "Processing Large Amounts of Data with APIs (Part 1 of 2)"
    author: Salesforce
    url: https://developer.salesforce.com/blogs/2022/12/processing-large-amounts-of-data-with-apis-part-1-of-2
    type: official_docs
    published: 2022-12-01
    reliability: authoritative
  - id: src2
    title: "Fetch data in chunks using pagination from S/4 HANA Cloud's OData API"
    author: SAP Community
    url: https://community.sap.com/t5/enterprise-resource-planning-blog-posts-by-members/fetch-data-in-chunks-using-pagination-from-s-4-hana-cloud-s-odata-api/ba-p/13572611
    type: technical_blog
    published: 2024-03-15
    reliability: moderate_high
  - id: src3
    title: "REST API for Common Features in Oracle Fusion Cloud Applications"
    author: Oracle
    url: https://docs.oracle.com/en/cloud/saas/applications-common/24c/farca/Manage_Collections.html
    type: official_docs
    published: 2024-10-01
    reliability: authoritative
  - id: src4
    title: "NetSuite Collection Paging"
    author: Oracle
    url: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156414087576.html
    type: official_docs
    published: 2025-01-01
    reliability: authoritative
  - id: src5
    title: "Pagination Methods in Dynamics 365 Integration: An In-Depth Guide"
    author: Smart Consulting
    url: https://www.hellosmart.ca/2023/09/pagination-methods-in-dynamics-365.html
    type: technical_blog
    published: 2023-09-15
    reliability: moderate_high
  - id: src6
    title: "Scaling Workday Integrations With MuleSoft: A Guide to Parallel Pagination"
    author: MuleSoft
    url: https://blogs.mulesoft.com/dev-guides/workday-integrations-and-parallel-pagination/
    type: technical_blog
    published: 2024-06-01
    reliability: moderate_high
  - id: src7
    title: "How to Implement REST API Pagination: Offset, Cursor, Keyset"
    author: Stainless
    url: https://www.stainless.com/sdk-api-best-practices/how-to-implement-rest-api-pagination-offset-cursor-keyset
    type: technical_blog
    published: 2024-08-01
    reliability: moderate_high
  - id: src8
    title: "Use $skiptoken for server side paging - OData"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/odata/webapi/skiptoken-for-server-side-paging
    type: official_docs
    published: 2024-01-01
    reliability: authoritative
---

# ERP API Pagination Patterns Comparison: Cursor vs Offset vs Keyset vs nextRecordsUrl

## TL;DR

- **Bottom line**: Every major ERP uses a different pagination pattern -- Salesforce uses server-side cursor (nextRecordsUrl), SAP and D365 use OData $skiptoken for server-driven paging, Oracle uses offset/limit, and NetSuite uses offset with a hard 1,000-page cap. Cursor-based pagination scales; offset-based breaks past 100K records.
- **Key limit**: NetSuite caps total retrievable records at pageSize x 1,000 pages -- if you have 500K records with page size 100, you can only retrieve 100K. Use SuiteQL with OFFSET or Saved Search CSV export for larger sets.
- **Watch out for**: Offset pagination under concurrent writes causes duplicate or missing records on every ERP -- Salesforce and D365 solve this with cursor/skiptoken; SAP, Oracle, NetSuite do not by default.
- **Best for**: This card answers "which pagination approach does my target ERP support and how does it scale?" -- use before designing any data extraction integration.
- **Authentication**: Pagination tokens are session-scoped -- if your OAuth token expires mid-pagination, most ERPs invalidate the cursor/offset state and you must restart.

## System Profile

This comparison card covers pagination patterns across 8 ERP systems: Salesforce, SAP S/4HANA Cloud, Oracle ERP Cloud (Fusion), Oracle NetSuite, Microsoft Dynamics 365 F&O, Workday, IFS Cloud, and Acumatica. The focus is on outbound data retrieval pagination (reading data from the ERP), not inbound bulk import patterns.

Each ERP implements pagination differently based on its API protocol (REST, OData, SOAP). Some support multiple pagination methods; the table below maps the primary and fallback approaches.

| System | Role | API Surface | Pagination Pattern |
|---|---|---|---|
| Salesforce | CRM + Platform | REST API v62.0 | Server-side cursor (nextRecordsUrl) |
| SAP S/4HANA Cloud | ERP | OData V2/V4 | $skip/$top (client) + $skiptoken (server-driven) |
| Oracle ERP Cloud | ERP | REST | Offset/limit with hasMore |
| Oracle NetSuite | ERP | REST + SuiteQL | Offset/limit with 1,000-page cap |
| Dynamics 365 F&O | ERP | OData V4 | @odata.nextLink + $skiptoken |
| Workday | HCM + Finance | REST + SOAP + RaaS | Page/offset (REST), no pagination (RaaS) |
| IFS Cloud | ERP | OData V4 | $skip/$top + server-driven paging |
| Acumatica | ERP | REST | $top/$skip + filter-based keyset |

## API Surfaces & Capabilities

| System | Pagination Method | Protocol | Default Page Size | Max Page Size | Stateful? | Deep Pagination? |
|---|---|---|---|---|---|---|
| Salesforce | nextRecordsUrl (cursor) | REST/JSON | 2,000 | 2,000 (fixed) | Yes (server cursor) | Yes -- O(1) per page |
| SAP S/4HANA | $skiptoken (server) | OData V4 | Server-controlled | Server-controlled | Yes (skiptoken) | Yes with $skiptoken |
| SAP S/4HANA | $skip/$top (client) | OData V2/V4 | Client-set | 10,000 typical | No (stateless) | No -- O(n) degradation |
| Oracle ERP Cloud | offset/limit | REST/JSON | 25 | 500 | No (stateless) | No -- O(n) degradation |
| NetSuite REST | offset/limit | REST/JSON | 1,000 | 1,000 | No (stateless) | Limited -- 1,000 page cap |
| NetSuite SuiteQL | offset/limit | REST/JSON | 1,000 | 1,000 | No (stateless) | Same 1,000 page cap |
| Dynamics 365 | @odata.nextLink | OData V4 | 10,000 | 10,000 (maxpagesize) | Yes (skiptoken) | Yes with $skiptoken |
| Workday REST | page/offset | REST/JSON | 10 | 100 | No (stateless) | No -- limited to REST endpoints |
| Workday RaaS | None native | REST/XML/JSON | Full report | Full report | N/A | N/A -- no pagination |
| IFS Cloud | $skip/$top + nextLink | OData V4 | Server-controlled | 10,000 typical | Mixed | Partial with server-driven |
| Acumatica | $top/$skip | REST/JSON | 100 | No hard cap | No (stateless) | No -- O(n) degradation |

## Rate Limits & Quotas

### Pagination-Specific Limits

| System | Pagination Limit | Implication | Workaround |
|---|---|---|---|
| Salesforce | Query locator expires after 15 min idle | Must consume pages within 15 min or restart | Reduce page processing time; parallelize downstream |
| SAP S/4HANA | $skiptoken validity tied to session | Token invalidated on session expiry | Re-authenticate and restart from last known position |
| Oracle ERP Cloud | 500 records per page max | 200 pages for 100K records | Use orderBy for consistent paging; BICC for bulk |
| NetSuite | 1,000 pages max total | Hard cap on retrievable records | Use date filters to chunk; Saved Search CSV for bulk |
| Dynamics 365 | 10,000 per page max; throttled at 6K req/5min | Throughput ceiling ~2M records/5min theoretical max | Use Data Management Framework for full exports |
| Workday RaaS | No pagination; full report in one call | Reports >50K rows timeout | Use WQL API or split with prompt parameters |
| Acumatica | No page limit; linear degradation | Offset 100K+ takes 10s+ per page | Use filter-based keyset (LastModifiedDate > X) |

### Concurrent Pagination Sessions

| System | Max Concurrent Sessions | Shared With | Notes |
|---|---|---|---|
| Salesforce | No explicit limit (API call quota applies) | All REST API calls | Each page costs 1 API call from 100K/24h pool |
| SAP S/4HANA | Tenant-specific | All OData requests | Contact SAP for exact concurrency limits |
| Oracle ERP Cloud | No documented limit | All REST API calls | Throttled by overall API throughput |
| NetSuite | 5-20 concurrent requests (by tier) | All SuiteTalk/REST calls | Standard: 5, Premium: 15, Enterprise: 20 |
| Dynamics 365 | Per-user throttled | All OData requests | 6,000 requests per 5-min per user per web server |
| Workday | Varies by endpoint | All API calls | WQL API: limited concurrency |

## Authentication

Authentication is not the focus of this card, but pagination tokens are tightly coupled to auth sessions:

| System | Token-Pagination Coupling | Impact of Token Expiry |
|---|---|---|
| Salesforce | nextRecordsUrl contains query locator tied to session | Token expiry invalidates all open query locators |
| SAP S/4HANA | $skiptoken valid within session | Must re-authenticate and restart |
| Oracle ERP Cloud | Stateless offset -- no coupling | Can resume with new token at same offset (if data unchanged) |
| NetSuite | Stateless offset -- no coupling | Can resume with new token at same offset |
| Dynamics 365 | $skiptoken may be session-scoped | @odata.nextLink may fail after token refresh |
| Acumatica | Stateless offset -- no coupling | Can resume with new token at same offset |

### Authentication Gotchas

- Salesforce query locators expire after 15 minutes of inactivity regardless of OAuth token validity -- long processing between pages kills the cursor. [src1]
- Dynamics 365 @odata.nextLink URLs embed server-specific routing -- do NOT modify the nextLink URL or extract parameters from it. [src5]
- NetSuite TBA signatures must be regenerated per request -- you cannot reuse the Authorization header from the first page request on subsequent pages. [src4]

## Constraints
<!-- Agents: read this section before recommending any integration approach from this unit.
     These are hard constraints that must not be violated. -->

- Salesforce: Page size is server-controlled at 2,000 records -- you cannot request larger pages even with LIMIT in SOQL
- SAP S/4HANA: Client-driven $skip/$top paging is not safe under concurrent writes -- rows shift, causing duplicates or gaps
- Oracle ERP Cloud: Results using offset/limit are NOT ordered by default -- you MUST add orderBy to get consistent pagination
- NetSuite: The 1,000-page cap means maximum retrievable records = page_size x 1,000 (e.g., 1,000 x 1,000 = 1M max)
- Dynamics 365: $skip is deprecated for server-driven paging in favor of $skiptoken -- new integrations must use @odata.nextLink
- Workday: RaaS has zero pagination -- if your report returns >50K rows, it will timeout; use WQL or chunk via prompt parameters
- Acumatica: No hard limit on $skip value, but database scan time grows linearly -- 100K+ offset causes multi-second delays
- All systems: Offset-based pagination has O(n) performance -- the database scans and discards all rows before the offset

## Integration Pattern Decision Tree

```
START -- Need to paginate through ERP data
|
+-- Which ERP?
|   |
|   +-- Salesforce
|   |   +-- Use REST API query endpoint
|   |   +-- Pagination: automatic nextRecordsUrl (cursor)
|   |   +-- Page size: fixed 2,000 records
|   |   +-- Deep pagination: YES (O(1) per page)
|   |   +-- > 10M records? --> Use Bulk API 2.0 instead
|   |
|   +-- SAP S/4HANA
|   |   +-- Data changing during pagination?
|   |   |   +-- YES --> Use server-driven paging ($skiptoken via Prefer header)
|   |   |   +-- NO --> $skip/$top is acceptable for < 100K records
|   |   +-- > 100K records? --> Use CDS views with extraction or BICC
|   |
|   +-- Oracle ERP Cloud
|   |   +-- Always add ?orderBy=... to prevent inconsistent results
|   |   +-- < 100K records --> offset/limit pagination works
|   |   +-- > 100K records --> Use BICC or BI Publisher scheduled extracts
|   |
|   +-- NetSuite
|   |   +-- Total records < pageSize x 1,000?
|   |   |   +-- YES --> offset/limit pagination is fine
|   |   |   +-- NO --> Use date-range chunking or Saved Search CSV export
|   |   +-- Prefer SuiteQL over REST record API for complex queries
|   |
|   +-- Dynamics 365 F&O
|   |   +-- Always use @odata.nextLink from response (don't construct manually)
|   |   +-- Server returns $skiptoken-based nextLink automatically
|   |   +-- > 500K records? --> Use Data Management Framework (DMF)
|   |
|   +-- Workday
|   |   +-- REST API endpoints --> page/offset (max 100/page)
|   |   +-- RaaS reports --> NO pagination; chunk with prompt parameters
|   |   +-- > 50K records in RaaS --> Use WQL API instead
|   |
|   +-- IFS Cloud
|   |   +-- Uses OData V4 server-driven paging (same as D365 pattern)
|   |   +-- Follow @odata.nextLink for each page
|   |   +-- Large extracts --> Use IFS Data Lake or integration framework
|   |
|   +-- Acumatica
|       +-- < 50K records --> $top/$skip is workable
|       +-- > 50K records --> Use filter-based keyset pagination
|       +-- Filter: LastModifiedDateTime gt 'YYYY-MM-DDTHH:mm:ss' + $top=N
|
+-- Performance check
    +-- < 10K records total --> Any pagination method works
    +-- 10K-100K records --> Prefer cursor/keyset if available
    +-- > 100K records --> MUST use cursor/keyset or bulk API
    +-- > 1M records --> Use bulk export API, not pagination
```

## Quick Reference

### Cross-System Pagination Comparison

| Capability | Salesforce | SAP S/4HANA | Oracle ERP Cloud | NetSuite | Dynamics 365 | Workday | IFS Cloud | Acumatica |
|---|---|---|---|---|---|---|---|---|
| **Primary Pattern** | Cursor (nextRecordsUrl) | $skiptoken (server) | Offset/limit | Offset/limit | $skiptoken (@odata.nextLink) | Page/offset (REST) | $skiptoken (server) | $top/$skip |
| **Fallback Pattern** | GraphQL cursors | $skip/$top (client) | None | SuiteQL offset | $skip/$top (deprecated) | Prompt chunking (RaaS) | $skip/$top | Filter-based keyset |
| **Default Page Size** | 2,000 | Server-controlled | 25 | 1,000 | 10,000 | 10 (REST) | Server-controlled | 100 |
| **Max Page Size** | 2,000 (fixed) | ~10,000 | 500 | 1,000 | 10,000 | 100 (REST) | ~10,000 | No hard cap |
| **Deep Pagination (>100K)** | Excellent (O(1)) | Good ($skiptoken) | Poor (O(n)) | Capped at 1K pages | Good ($skiptoken) | Very poor | Good (server) | Poor (O(n)) |
| **Data Consistency** | Strong (cursor) | Strong ($skiptoken) | Weak (offset) | Weak (offset) | Strong ($skiptoken) | Weak (offset) | Mixed | Weak (offset) |
| **Stateful/Stateless** | Stateful | Stateful ($skiptoken) | Stateless | Stateless | Stateful | Stateless | Mixed | Stateless |
| **Cursor Expiry** | 15 min idle | Session-scoped | N/A | N/A | Session-scoped | N/A | Session-scoped | N/A |
| **Total Record Cap** | None (API quota) | None | None | pageSize x 1,000 | None | Report-dependent | None | None |
| **Resumable After Failure** | No (cursor lost) | No (restart) | Yes (stateless) | Yes (stateless) | No (restart) | Yes (stateless) | Depends | Yes (stateless) |

### Pagination Pattern Types Explained

| Pattern | How It Works | Time Complexity | Consistency | Jump to Page N? | Best For |
|---|---|---|---|---|---|
| **Cursor / nextRecordsUrl** | Server returns opaque token pointing to next result set | O(1) per page | Strong -- immune to inserts/deletes | No | Large, volatile datasets |
| **Offset / $skip** | Client specifies row number to start from | O(n) -- DB scans to offset | Weak -- shifts on insert/delete | Yes | Small, static datasets |
| **Keyset / filter-based** | Client filters by last-seen value (e.g., ID > last_id) | O(1) per page | Strong if sorted by unique key | No (forward only) | Medium-large, sorted data |
| **$skiptoken (OData)** | Server-generated opaque token encapsulating position state | O(1) per page | Strong | No | OData APIs (SAP, D365, IFS) |
| **Page number** | Client requests page N of fixed size | O(n) internally | Weak | Yes | Small REST APIs |

## Step-by-Step Integration Guide

### 1. Salesforce: Paginate with nextRecordsUrl

Salesforce REST API returns a `nextRecordsUrl` field when query results exceed 2,000 records. This URL contains a server-side query locator that acts as a cursor. [src1]

```python
import requests

def paginate_salesforce(instance_url, access_token, soql_query):
    """Paginate through Salesforce SOQL query results using nextRecordsUrl cursor."""
    headers = {"Authorization": f"Bearer {access_token}"}
    url = f"{instance_url}/services/data/v62.0/query"
    params = {"q": soql_query}
    all_records = []

    while True:
        resp = requests.get(url, headers=headers, params=params)
        resp.raise_for_status()
        data = resp.json()
        all_records.extend(data["records"])

        if data["done"]:
            break

        # nextRecordsUrl is a relative path -- prepend instance URL
        url = f"{instance_url}{data['nextRecordsUrl']}"
        params = {}  # nextRecordsUrl contains all state

    return all_records

# Usage: returns all records, handling pagination automatically
# records = paginate_salesforce(instance_url, token, "SELECT Id, Name FROM Account")
```

**Verify**: Check `data["totalSize"]` matches `len(all_records)` after loop completes.

### 2. SAP S/4HANA: Server-Driven Paging with $skiptoken

For OData V4 endpoints, set the `Prefer: odata.maxpagesize=N` header to enable server-driven paging. The response includes `@odata.nextLink` with a `$skiptoken` parameter. [src2]

```python
import requests

def paginate_sap_odata(base_url, access_token, entity_set, page_size=1000):
    """Paginate SAP S/4HANA OData V4 using server-driven $skiptoken paging."""
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Prefer": f"odata.maxpagesize={page_size}",
        "Accept": "application/json"
    }
    url = f"{base_url}/sap/opu/odata4/sap/{entity_set}"
    all_records = []

    while url:
        resp = requests.get(url, headers=headers)
        resp.raise_for_status()
        data = resp.json()
        all_records.extend(data.get("value", []))

        # Server provides nextLink with $skiptoken if more data exists
        url = data.get("@odata.nextLink")
        # Do NOT modify the nextLink URL -- it contains server state

    return all_records
```

**Verify**: Compare final record count against `$count` endpoint: `GET {entity_set}/$count`.

### 3. Oracle ERP Cloud: Offset/Limit with orderBy

Oracle Fusion REST API uses stateless offset/limit pagination. Always include `orderBy` to prevent inconsistent results across pages. [src3]

```python
import requests

def paginate_oracle_erp(base_url, access_token, resource, page_size=500):
    """Paginate Oracle ERP Cloud REST API using offset/limit with orderBy."""
    headers = {"Authorization": f"Bearer {access_token}"}
    all_records = []
    offset = 0

    while True:
        params = {
            "limit": page_size,
            "offset": offset,
            "orderBy": "CreationDate:asc",  # REQUIRED for consistent pagination
            "totalResults": "true"
        }
        resp = requests.get(f"{base_url}/fscmRestApi/resources/latest/{resource}",
                           headers=headers, params=params)
        resp.raise_for_status()
        data = resp.json()
        items = data.get("items", [])
        all_records.extend(items)

        if not data.get("hasMore", False):
            break

        offset += page_size

    return all_records
```

**Verify**: `data["totalResults"]` from first response should equal `len(all_records)` on completion.

### 4. NetSuite: Offset/Limit with Page Cap Awareness

NetSuite REST API uses offset/limit pagination with a hard cap of 1,000 pages. For datasets exceeding this cap, use date-range chunking. [src4]

```python
import requests
from requests_oauthlib import OAuth1

def paginate_netsuite(account_id, auth, resource, page_size=1000):
    """Paginate NetSuite REST API. CAUTION: max 1,000 pages total."""
    base_url = f"https://{account_id}.suitetalk.api.netsuite.com/services/rest/record/v1/{resource}"
    all_records = []
    offset = 0
    max_records = page_size * 1000  # Hard cap

    while offset < max_records:
        params = {"limit": page_size, "offset": offset}
        resp = requests.get(base_url, auth=auth, params=params)
        resp.raise_for_status()
        data = resp.json()
        items = data.get("items", [])
        all_records.extend(items)

        if not data.get("hasMore", False):
            break

        offset += page_size

    if offset >= max_records and data.get("hasMore"):
        raise RuntimeError(
            f"Hit NetSuite 1,000-page cap at {max_records} records. "
            "Use date-range chunking or SuiteQL with filters."
        )

    return all_records
```

**Verify**: If function raises RuntimeError, switch to SuiteQL with `WHERE lastmodifieddate > '...'` filters.

### 5. Dynamics 365: Follow @odata.nextLink

Dynamics 365 F&O returns `@odata.nextLink` for server-driven paging. The link contains a `$skiptoken` with encoded cursor state. [src5, src8]

```python
import requests

def paginate_dynamics365(base_url, access_token, entity, max_page_size=10000):
    """Paginate Dynamics 365 F&O using @odata.nextLink with $skiptoken."""
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Prefer": f"odata.maxpagesize={max_page_size}",
        "Accept": "application/json"
    }
    url = f"{base_url}/data/{entity}"
    all_records = []

    while url:
        resp = requests.get(url, headers=headers)
        resp.raise_for_status()
        data = resp.json()
        all_records.extend(data.get("value", []))

        # NEVER modify or reconstruct the nextLink URL
        url = data.get("@odata.nextLink")

    return all_records
```

**Verify**: Run `GET {entity}/$count` and compare to `len(all_records)`.

### 6. Acumatica: Filter-Based Keyset for Large Datasets

For datasets over 50K records, avoid $skip and use filter-based keyset pagination by sorting on a unique field and filtering by last-seen value.

```python
import requests

def paginate_acumatica_keyset(base_url, session_cookies, entity, page_size=100):
    """Paginate Acumatica using filter-based keyset for O(1) performance."""
    all_records = []
    last_id = ""

    while True:
        url = f"{base_url}/entity/Default/24.200.001/{entity}"
        params = {"$top": page_size, "$orderby": "InventoryID"}
        if last_id:
            params["$filter"] = f"InventoryID gt '{last_id}'"

        resp = requests.get(url, cookies=session_cookies, params=params)
        resp.raise_for_status()
        records = resp.json()

        if not records:
            break

        all_records.extend(records)
        last_id = records[-1]["InventoryID"]["value"]

    return all_records
```

**Verify**: Compare count against Acumatica Generic Inquiry for the same entity.

## Code Examples

### Python: Universal ERP Paginator with Pattern Detection

```python
# Input:  ERP system name, connection config, query parameters
# Output: All records from paginated API response

import time
import requests

class ERPPaginator:
    """Universal paginator that detects and handles ERP-specific pagination patterns."""

    PATTERNS = {
        "salesforce": "cursor",      # nextRecordsUrl
        "sap_s4hana": "skiptoken",   # @odata.nextLink + $skiptoken
        "oracle_erp": "offset",      # offset/limit + hasMore
        "netsuite": "offset_capped", # offset/limit + 1,000 page cap
        "dynamics365": "skiptoken",  # @odata.nextLink + $skiptoken
        "workday": "page",           # page/offset (REST only)
        "ifs_cloud": "skiptoken",    # OData server-driven
        "acumatica": "offset",       # $top/$skip
    }

    def __init__(self, system, headers, max_retries=3):
        self.system = system
        self.headers = headers
        self.max_retries = max_retries
        self.pattern = self.PATTERNS.get(system, "offset")

    def paginate(self, url, page_size=None):
        all_records = []
        params = {}

        if self.pattern == "cursor":
            return self._cursor_paginate(url)
        elif self.pattern == "skiptoken":
            return self._skiptoken_paginate(url, page_size)
        elif self.pattern == "offset_capped":
            return self._offset_paginate(url, page_size, max_pages=1000)
        else:
            return self._offset_paginate(url, page_size)

    def _cursor_paginate(self, url):
        """Salesforce-style: follow nextRecordsUrl cursor."""
        records = []
        while url:
            data = self._request(url)
            records.extend(data.get("records", []))
            url = data.get("nextRecordsUrl")
        return records

    def _skiptoken_paginate(self, url, page_size):
        """OData-style: follow @odata.nextLink with $skiptoken."""
        records = []
        if page_size:
            self.headers["Prefer"] = f"odata.maxpagesize={page_size}"
        while url:
            data = self._request(url)
            records.extend(data.get("value", []))
            url = data.get("@odata.nextLink")
        return records

    def _offset_paginate(self, url, page_size, max_pages=None):
        """Generic offset/limit pagination."""
        records = []
        offset = 0
        page_count = 0
        ps = page_size or 500

        while True:
            if max_pages and page_count >= max_pages:
                break
            data = self._request(url, params={"limit": ps, "offset": offset})
            items = data.get("items", data.get("records", []))
            records.extend(items)

            if not data.get("hasMore", False) or not items:
                break
            offset += ps
            page_count += 1

        return records

    def _request(self, url, params=None):
        """HTTP GET with retry and rate limit handling."""
        for attempt in range(self.max_retries):
            resp = requests.get(url, headers=self.headers, params=params)
            if resp.status_code == 429:
                wait = 2 ** attempt
                time.sleep(wait)
                continue
            resp.raise_for_status()
            return resp.json()
        raise RuntimeError(f"Max retries exceeded for {url}")
```

### cURL: Test Pagination for Each ERP

```bash
# Salesforce: Query with automatic cursor pagination
curl -s -H "Authorization: Bearer $SF_TOKEN" \
  "$SF_INSTANCE/services/data/v62.0/query?q=SELECT+Id,Name+FROM+Account" \
  | jq '{totalSize: .totalSize, done: .done, nextUrl: .nextRecordsUrl, count: (.records | length)}'

# SAP S/4HANA: Server-driven paging
curl -s -H "Authorization: Bearer $SAP_TOKEN" \
  -H "Prefer: odata.maxpagesize=100" \
  "$SAP_URL/sap/opu/odata4/sap/API_BUSINESS_PARTNER/A_BusinessPartner" \
  | jq '{count: (.value | length), nextLink: ."@odata.nextLink"}'

# Oracle ERP Cloud: Offset pagination
curl -s -H "Authorization: Bearer $ORA_TOKEN" \
  "$ORA_URL/fscmRestApi/resources/latest/invoices?limit=500&offset=0&orderBy=InvoiceId:asc&totalResults=true" \
  | jq '{totalResults: .totalResults, hasMore: .hasMore, count: (.items | length)}'

# NetSuite: Offset with page cap check
curl -s -H "Authorization: $NS_AUTH" \
  "https://$NS_ACCOUNT.suitetalk.api.netsuite.com/services/rest/record/v1/customer?limit=1000&offset=0" \
  | jq '{hasMore: .hasMore, count: (.items | length), totalResults: .totalResults}'

# Dynamics 365: Follow nextLink
curl -s -H "Authorization: Bearer $D365_TOKEN" \
  -H "Prefer: odata.maxpagesize=5000" \
  "$D365_URL/data/SalesOrderHeaders" \
  | jq '{count: (.value | length), nextLink: ."@odata.nextLink"}'
```

## Error Handling & Failure Points

### Common Error Codes

| System | Error | Cause | Resolution |
|---|---|---|---|
| Salesforce | `QUERY_TIMEOUT` | Query locator expired (>15 min idle) | Reduce per-page processing time; restart query |
| Salesforce | `INVALID_QUERY_LOCATOR` | Used expired or invalid nextRecordsUrl | Re-execute original SOQL query from scratch |
| SAP S/4HANA | `400 Bad Request` on $skip | $skip value exceeds available records | Check $count first; use $skiptoken instead |
| Oracle ERP Cloud | `500 Internal Server Error` at high offset | DB timeout scanning to large offset | Add restrictive filters; use BICC for bulk |
| NetSuite | `SSS_REQUEST_LIMIT_EXCEEDED` | Hit concurrency or governance limit | Implement exponential backoff; reduce concurrent sessions |
| Dynamics 365 | `429 Too Many Requests` | Exceeded 6K req/5min per user | Wait for Retry-After header; use batch $batch requests |
| Workday | `408 Request Timeout` | RaaS report too large | Split with prompt parameters; use WQL API |
| Acumatica | `Timeout` at large $skip values | DB scan degradation at high offsets | Switch to filter-based keyset pagination |

### Failure Points in Production

- **Salesforce cursor expiry mid-extraction**: If downstream processing (writing to target system) takes >15 min per 2,000-record page, the nextRecordsUrl expires. Fix: `Decouple read and write -- buffer all pages to local storage first, then write`. [src1]
- **SAP duplicate records with $skip/$top**: Under concurrent writes, $skip-based paging returns duplicate or missing records because row positions shift. Fix: `Use server-driven paging via Prefer: odata.maxpagesize=N header to get $skiptoken-based @odata.nextLink`. [src2]
- **Oracle silent data inconsistency**: Offset/limit without orderBy returns non-deterministic results -- the same record can appear on multiple pages or be skipped entirely. Fix: `Always include orderBy on a unique, immutable column (e.g., primary key or CreationDate)`. [src3]
- **NetSuite hitting 1,000-page ceiling**: Integration silently stops receiving records at page 1,000 with hasMore=false. Fix: `Implement date-range chunking: paginate within lastmodifieddate windows narrow enough to stay under 1,000 pages each`. [src4]
- **D365 $skip deprecation breakage**: Older integrations using manual $skip construction break after platform update to $skiptoken. Fix: `Always follow @odata.nextLink from response -- never construct pagination URLs manually`. [src5, src8]
- **Workday RaaS timeout on large datasets**: Reports with >50K rows fail with timeout before returning any data. Fix: `Add prompts (date ranges, department filters) to chunk the report, or migrate to WQL API which supports pagination`. [src6]

## Anti-Patterns

### Wrong: Using $skip/$top for deep pagination on SAP

```python
# BAD -- $skip at high values causes O(n) scan + data consistency issues
offset = 0
while True:
    url = f"{base_url}/API_BUSINESS_PARTNER?$top=1000&$skip={offset}"
    data = requests.get(url, headers=headers).json()
    records.extend(data["value"])
    if len(data["value"]) < 1000:
        break
    offset += 1000  # At offset 500K, this query takes 30+ seconds
```

### Correct: Use server-driven $skiptoken paging

```python
# GOOD -- O(1) per page, data-consistent, server manages cursor state
headers["Prefer"] = "odata.maxpagesize=1000"
url = f"{base_url}/API_BUSINESS_PARTNER"
while url:
    data = requests.get(url, headers=headers).json()
    records.extend(data["value"])
    url = data.get("@odata.nextLink")  # Contains $skiptoken -- don't modify
```

### Wrong: Ignoring orderBy on Oracle ERP Cloud offset pagination

```python
# BAD -- without orderBy, results are non-deterministic across pages
offset = 0
while True:
    params = {"limit": 500, "offset": offset}
    data = requests.get(f"{url}/invoices", params=params).json()
    # Records from page 1 may reappear on page 3 or be skipped entirely
```

### Correct: Always include orderBy for consistent offset pagination

```python
# GOOD -- orderBy on unique column guarantees deterministic page ordering
offset = 0
while True:
    params = {"limit": 500, "offset": offset, "orderBy": "InvoiceId:asc"}
    data = requests.get(f"{url}/invoices", params=params).json()
    # Each record appears exactly once across all pages
```

### Wrong: Assuming unlimited pagination on NetSuite

```python
# BAD -- code has no awareness of the 1,000-page cap
all_records = []
offset = 0
while True:
    data = requests.get(f"{url}?limit=100&offset={offset}").json()
    all_records.extend(data["items"])
    if not data["hasMore"]:
        break  # NetSuite silently sets hasMore=false at page 1,000
    offset += 100
# With limit=100, you only get 100,000 records even if 500K exist
```

### Correct: Detect the cap and use date-range chunking

```python
# GOOD -- detect cap risk and chunk by date range
import datetime

def paginate_netsuite_chunked(url, auth, start_date, end_date, chunk_days=7):
    all_records = []
    current = start_date
    while current < end_date:
        chunk_end = min(current + datetime.timedelta(days=chunk_days), end_date)
        # Chunk by date to keep each window under 1,000 pages
        filter_url = f"{url}?limit=1000&offset=0&q=lastModifiedDate BETWEEN '{current}' AND '{chunk_end}'"
        records = paginate_within_cap(filter_url, auth, page_size=1000)
        all_records.extend(records)
        current = chunk_end
    return all_records
```

## Common Pitfalls

- **Assuming all ERPs support cursor pagination**: Only Salesforce and OData-based systems (SAP, D365, IFS) offer true cursor/skiptoken pagination. Oracle ERP Cloud and NetSuite are offset-only. Fix: `Check this comparison table before designing your integration pattern`. [src7]
- **Constructing pagination URLs manually**: D365 @odata.nextLink and SAP $skiptoken URLs are opaque -- extracting and reconstructing parameters breaks pagination. Fix: `Always use the complete nextLink URL from the response as-is`. [src5, src8]
- **Not handling token expiry during long pagination**: A 500K-record extraction on Salesforce takes ~250 pages x network latency. If OAuth token expires mid-stream, the cursor dies. Fix: `Pre-calculate extraction time, refresh token proactively before expiry, or buffer-then-process`. [src1]
- **Using large page sizes to "optimize" requests**: Requesting max page size (e.g., 10,000 on D365) increases memory pressure and timeout risk on the server. Fix: `Use moderate page sizes (500-2,000) for reliable throughput -- the throughput/reliability sweet spot is usually 1,000`. [src5]
- **Ignoring the NetSuite 1,000-page cap**: Developers assume hasMore=false means all records were returned. NetSuite silently stops at 1,000 pages. Fix: `If total record count is known, verify retrieved count matches. If not, implement date-range chunking`. [src4]
- **Running offset pagination on volatile data**: Records inserted or deleted between page requests cause duplicates or gaps with offset-based pagination on all ERPs. Fix: `Use cursor/skiptoken where available; for offset-only ERPs, paginate on immutable columns (creation date, auto-increment ID)`. [src7]

## Diagnostic Commands

```bash
# Salesforce: Check how many records a query will return (before paginating)
curl -s -H "Authorization: Bearer $SF_TOKEN" \
  "$SF_INSTANCE/services/data/v62.0/query?q=SELECT+COUNT()+FROM+Account" \
  | jq '.records[0].expr0'

# SAP S/4HANA: Get entity count via $count
curl -s -H "Authorization: Bearer $SAP_TOKEN" \
  "$SAP_URL/sap/opu/odata4/sap/API_BUSINESS_PARTNER/A_BusinessPartner/\$count"

# Oracle ERP Cloud: Get total results count
curl -s -H "Authorization: Bearer $ORA_TOKEN" \
  "$ORA_URL/fscmRestApi/resources/latest/invoices?limit=1&totalResults=true" \
  | jq '.totalResults'

# NetSuite: Check record count for page cap risk assessment
curl -s -H "Authorization: $NS_AUTH" \
  "https://$NS_ACCOUNT.suitetalk.api.netsuite.com/services/rest/record/v1/customer?limit=1" \
  | jq '.totalResults'

# Dynamics 365: Get entity count
curl -s -H "Authorization: Bearer $D365_TOKEN" \
  "$D365_URL/data/SalesOrderHeaders/\$count"

# Workday: Test WQL pagination capability
curl -s -H "Authorization: Bearer $WD_TOKEN" \
  "$WD_URL/api/wql/v1/data?query=SELECT+worker+FROM+allWorkers&limit=1&offset=0" \
  | jq '.total'
```

## Version History & Compatibility

| ERP | Feature | Introduced | Status | Notes |
|---|---|---|---|---|
| Salesforce | nextRecordsUrl cursor | API v20+ (2010) | Stable | Unchanged for 15+ years |
| Salesforce | GraphQL cursor pagination | API v56.0 (2022) | GA | Alternative for complex queries |
| SAP S/4HANA | OData V4 $skiptoken | 2020 | Current | Preferred over $skip/$top |
| SAP S/4HANA | OData V2 $skip/$top | 2015 | Supported | Not recommended for new dev |
| Oracle ERP Cloud | REST offset/limit | 2017 | Current | No cursor alternative planned |
| NetSuite | REST API pagination | 2019.2 | Current | 1,000-page cap since introduction |
| NetSuite | SuiteQL pagination | 2020.2 | Current | Same cap as REST |
| Dynamics 365 | @odata.nextLink | 2016 | Current | $skiptoken adopted 2023+ |
| Dynamics 365 | $skip (manual) | 2016 | Deprecated | Replaced by $skiptoken |
| Workday | REST pagination | 2020 | Current | 100/page max |
| Workday | WQL pagination | 2022 | Current | Alternative to RaaS |
| IFS Cloud | OData V4 paging | IFS Cloud 22R1 | Current | Standard OData pattern |
| Acumatica | REST $top/$skip | 2019 R1 | Current | No cursor alternative |

### Deprecation Policy

Salesforce supports API versions for a minimum of 3 years; their pagination mechanism has been stable since v20. SAP follows OData standards and deprecates V2 gradually in favor of V4. Microsoft is actively deprecating $skip-based server-driven paging in D365 in favor of $skiptoken. Oracle and NetSuite have no announced changes to their offset/limit pattern.

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| Extracting <100K records for reporting/sync | Extracting >1M records for data migration | Bulk API (Salesforce), BICC (Oracle), DMF (D365) |
| Building incremental data sync | Initial full data load | Bulk export APIs or file-based import/export |
| Feeding real-time dashboards with fresh data | Historical analytics over full dataset | Data warehouse / BICC / Prism Analytics |
| Moderate-volume scheduled integrations | High-frequency, low-latency event processing | CDC / Platform Events / webhooks |

## Cross-System Comparison

| Capability | Salesforce | SAP S/4HANA | Oracle ERP Cloud | NetSuite | Dynamics 365 | Workday | IFS Cloud | Acumatica |
|---|---|---|---|---|---|---|---|---|
| **Pagination Type** | Cursor | $skiptoken + $skip | Offset | Offset | $skiptoken | Page/offset | $skiptoken | $skip + keyset |
| **Consistency Guarantee** | Strong | Strong ($skiptoken) | None | None | Strong | None | Strong (server) | None |
| **Performance at 100K** | Excellent | Good | Degraded | Capped | Good | N/A (RaaS fails) | Good | Degraded |
| **Performance at 1M** | Good | Good | Very poor | Impossible | Good | N/A | Good | Very poor |
| **Max Records Retrievable** | Unlimited* | Unlimited | Unlimited** | ~1M (1K pages) | Unlimited | ~50K (RaaS) | Unlimited | Unlimited** |
| **Resumability** | No | No | Yes | Yes | No | Yes | Depends | Yes |
| **Random Page Access** | No | No | Yes | Yes | No | No | No | Yes |
| **Standard** | Proprietary | OData V4 | REST | REST | OData V4 | Proprietary | OData V4 | REST |
| **Learning Curve** | Low | Medium | Low | Low | Medium | High | Medium | Low |

*Subject to daily API call quota. **Subject to timeout at very high offsets.

## Important Caveats

- Pagination patterns change with API versions -- SAP's shift from $skip to $skiptoken and D365's deprecation of manual $skip are recent examples. Always verify against current release notes.
- Performance numbers (O(1) vs O(n)) are theoretical -- actual performance depends on database indexes, query complexity, and server load. Test with production-scale data.
- "Stateless" offset pagination is only safe if data does not change between requests. For volatile datasets, even Oracle and NetSuite require idempotency-aware logic.
- NetSuite's 1,000-page cap is not documented prominently -- many developers discover it only in production at scale.
- Workday RaaS "no pagination" limitation is architectural, not a bug -- Workday's recommendation is to use WQL or smaller report scopes, not to wait for pagination support.
- Token/cursor expiry times are configurable by admins on some platforms (Salesforce session timeout, D365 token lifetime) -- never hardcode expiry assumptions.
- This comparison covers cloud/SaaS deployments. On-premise versions (SAP ECC, D365 on-prem, IFS on-prem) may have different pagination behavior and limits.

## Related Units

- [ERP REST API Comparison](/business/erp-integration/erp-rest-api-comparison/2026) -- broader API capability comparison
- [ERP Rate Limits Comparison](/business/erp-integration/erp-rate-limits-comparison/2026) -- rate limits across ERPs
- [ERP Bulk Import Comparison](/business/erp-integration/erp-bulk-import-comparison/2026) -- when pagination is not enough
- [Change Data Capture Across ERPs](/business/erp-integration/change-data-capture-erp/2026) -- incremental sync alternative to full pagination
- [Batch vs Real-Time Integration](/business/erp-integration/batch-vs-realtime-integration/2026) -- choosing the right integration pattern
