This comparison card covers pagination patterns across 8 ERP systems. The focus is on outbound data retrieval pagination (reading data from the ERP), not inbound bulk import patterns.
| 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 |
| System | Pagination Method | Default Page Size | Max Page Size | Stateful? | Deep Pagination? |
|---|---|---|---|---|---|
| Salesforce | nextRecordsUrl (cursor) | 2,000 | 2,000 (fixed) | Yes (server cursor) | Yes -- O(1) |
| SAP S/4HANA ($skiptoken) | Server-driven | Server-controlled | Server-controlled | Yes | Yes |
| SAP S/4HANA ($skip/$top) | Client-driven | Client-set | ~10,000 | No | No -- O(n) |
| Oracle ERP Cloud | offset/limit | 25 | 500 | No | No -- O(n) |
| NetSuite REST | offset/limit | 1,000 | 1,000 | No | Limited (1K pages) |
| Dynamics 365 | @odata.nextLink | 10,000 | 10,000 | Yes ($skiptoken) | Yes |
| Workday REST | page/offset | 10 | 100 | No | No |
| Workday RaaS | None | Full report | Full report | N/A | N/A |
| IFS Cloud | $skip/$top + nextLink | Server-controlled | ~10,000 | Mixed | Partial |
| Acumatica | $top/$skip | 100 | No hard cap | No | No -- O(n) |
| System | Pagination Limit | Implication | Workaround |
|---|---|---|---|
| Salesforce | Query locator expires after 15 min idle | Must consume pages within 15 min | Reduce per-page processing time; parallelize downstream |
| SAP S/4HANA | $skiptoken tied to session | 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 | Add restrictive filters; use BICC for bulk |
| NetSuite | 1,000 pages max total | Hard cap on retrievable records | Date-range chunking or Saved Search CSV |
| Dynamics 365 | 10,000 per page max; 6K req/5min throttle | Throughput ceiling ~2M records/5min | Use Data Management Framework for full exports |
| Workday RaaS | No pagination; full report in one call | Reports >50K rows timeout | WQL API or split with prompt parameters |
| Acumatica | No page limit; linear degradation | Offset 100K+ takes 10s+ per page | Filter-based keyset (LastModifiedDate > X) |
| System | Max Concurrent | Shared With | Notes |
|---|---|---|---|
| Salesforce | No explicit limit | 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 limits |
| Oracle ERP Cloud | No documented limit | All REST API calls | Throttled by overall throughput |
| NetSuite | 5-20 (by tier) | All SuiteTalk/REST | Standard: 5, Premium: 15, Enterprise: 20 |
| Dynamics 365 | Per-user throttled | All OData requests | 6K req/5min per user per web server |
| System | Token-Pagination Coupling | Impact of Token Expiry |
|---|---|---|
| Salesforce | nextRecordsUrl 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 -- no coupling | Resume with new token at same offset |
| NetSuite | Stateless -- no coupling | Resume with new token at same offset |
| Dynamics 365 | $skiptoken may be session-scoped | @odata.nextLink may fail after refresh |
| Acumatica | Stateless -- no coupling | Resume with new token at same offset |
START -- Need to paginate through ERP data
|
+-- Which ERP?
| +-- Salesforce --> nextRecordsUrl cursor (2,000/page, O(1))
| | +-- >10M records? --> Bulk API 2.0 instead
| +-- SAP S/4HANA --> Server-driven $skiptoken (Prefer header)
| | +-- >100K records? --> CDS views or BICC
| +-- Oracle ERP Cloud --> offset/limit + orderBy (REQUIRED)
| | +-- >100K records? --> BICC or BI Publisher
| +-- NetSuite --> offset/limit (1,000 page cap!)
| | +-- >pageSize*1000? --> Date-range chunking
| +-- Dynamics 365 --> Follow @odata.nextLink (never construct manually)
| | +-- >500K records? --> Data Management Framework
| +-- Workday REST --> page/offset (max 100/page)
| | +-- RaaS reports --> No pagination; chunk with prompts
| +-- IFS Cloud --> OData V4 server-driven paging
| +-- Acumatica --> <50K: $top/$skip; >50K: filter-based keyset
|
+-- Performance: <10K: any method; 10K-100K: prefer cursor; >100K: cursor required; >1M: bulk API
| Capability | Salesforce | SAP S/4HANA | Oracle ERP | NetSuite | D365 | Workday | IFS | Acumatica |
|---|---|---|---|---|---|---|---|---|
| Primary Pattern | Cursor | $skiptoken | Offset | Offset | $skiptoken | Page/offset | $skiptoken | $top/$skip |
| Default Page Size | 2,000 | Server | 25 | 1,000 | 10,000 | 10 | Server | 100 |
| Max Page Size | 2,000 | ~10,000 | 500 | 1,000 | 10,000 | 100 | ~10,000 | No cap |
| Deep Paging (>100K) | Excellent | Good | Poor | Capped | Good | Very poor | Good | Poor |
| Data Consistency | Strong | Strong | Weak | Weak | Strong | Weak | Strong | Weak |
| Stateful | Yes | Yes | No | No | Yes | No | Mixed | No |
| Cursor Expiry | 15 min | Session | N/A | N/A | Session | N/A | Session | N/A |
| Total Record Cap | None* | None | None | ~1M | None | ~50K RaaS | None | None |
| Resumable | No | No | Yes | Yes | No | Yes | Depends | Yes |
| Pattern | How It Works | Time Complexity | Consistency | Jump to Page N? | Best For |
|---|---|---|---|---|---|
| Cursor / nextRecordsUrl | Server returns opaque token for next set | O(1) per page | Strong | No | Large, volatile datasets |
| Offset / $skip | Client specifies row offset | O(n) | Weak | Yes | Small, static datasets |
| Keyset / filter-based | Filter by last-seen value | O(1) per page | Strong if sorted | No | Medium-large, sorted data |
| $skiptoken (OData) | Server-generated opaque position token | O(1) per page | Strong | No | OData APIs (SAP, D365, IFS) |
| Page number | Client requests page N | O(n) | Weak | Yes | Small REST APIs |
Salesforce REST API returns a nextRecordsUrl field when results exceed 2,000 records. This URL contains a server-side query locator acting as a cursor. [src1]
import requests
def paginate_salesforce(instance_url, access_token, soql_query):
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
url = f"{instance_url}{data['nextRecordsUrl']}"
params = {}
return all_records
Verify: data["totalSize"] should match len(all_records)
Set Prefer: odata.maxpagesize=N header. Response includes @odata.nextLink with $skiptoken. [src2]
import requests
def paginate_sap_odata(base_url, access_token, entity_set, page_size=1000):
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", []))
url = data.get("@odata.nextLink") # Don't modify this URL
return all_records
Verify: Compare count against GET {entity_set}/$count
Always include orderBy -- results are non-deterministic without it. [src3]
import requests
def paginate_oracle_erp(base_url, access_token, resource, page_size=500):
headers = {"Authorization": f"Bearer {access_token}"}
all_records, offset = [], 0
while True:
params = {"limit": page_size, "offset": offset,
"orderBy": "CreationDate:asc", "totalResults": "true"}
resp = requests.get(f"{base_url}/fscmRestApi/resources/latest/{resource}",
headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
all_records.extend(data.get("items", []))
if not data.get("hasMore", False):
break
offset += page_size
return all_records
Verify: data["totalResults"] matches len(all_records)
D365 returns @odata.nextLink with $skiptoken. Never construct pagination URLs manually. [src5, src8]
import requests
def paginate_dynamics365(base_url, access_token, entity, max_page_size=10000):
headers = {"Authorization": f"Bearer {access_token}",
"Prefer": f"odata.maxpagesize={max_page_size}"}
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", []))
url = data.get("@odata.nextLink")
return all_records
Verify: GET {entity}/$count matches len(all_records)
For >50K records, avoid $skip and use filter-based keyset by sorting on a unique field.
import requests
def paginate_acumatica_keyset(base_url, cookies, entity, page_size=100):
all_records, last_id = [], ""
while True:
params = {"$top": page_size, "$orderby": "InventoryID"}
if last_id:
params["$filter"] = f"InventoryID gt '{last_id}'"
resp = requests.get(f"{base_url}/entity/Default/24.200.001/{entity}",
cookies=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 against Acumatica Generic Inquiry count
# Salesforce: Query with 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}'
# 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 (always include orderBy!)
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}'
# NetSuite: Check for 1,000-page cap risk
curl -s -H "Authorization: $NS_AUTH" \
"https://$NS_ACCOUNT.suitetalk.api.netsuite.com/services/rest/record/v1/customer?limit=1" \
| jq '.totalResults'
# Dynamics 365: Follow @odata.nextLink
curl -s -H "Authorization: Bearer $D365_TOKEN" \
-H "Prefer: odata.maxpagesize=5000" \
"$D365_URL/data/SalesOrderHeaders" \
| jq '{count: (.value | length), nextLink: ."@odata.nextLink"}'
| System | Error | Cause | Resolution |
|---|---|---|---|
| Salesforce | QUERY_TIMEOUT | Query locator expired (>15 min) | Reduce processing time; restart query |
| Salesforce | INVALID_QUERY_LOCATOR | Expired nextRecordsUrl | Re-execute original SOQL |
| SAP S/4HANA | 400 Bad Request on $skip | $skip exceeds available records | Check $count; use $skiptoken |
| Oracle ERP | 500 Internal Server Error | DB timeout at high offset | Add filters; use BICC for bulk |
| NetSuite | SSS_REQUEST_LIMIT_EXCEEDED | Concurrency/governance limit | Backoff; reduce concurrent sessions |
| Dynamics 365 | 429 Too Many Requests | Exceeded 6K req/5min | Respect Retry-After; use $batch |
| Workday | 408 Request Timeout | RaaS report too large | Split with prompts; use WQL |
| Acumatica | Timeout | DB scan at large $skip | Switch to filter-based keyset |
Buffer all pages locally first, then process. [src1]Use Prefer: odata.maxpagesize=N for $skiptoken-based paging. [src2]Always orderBy on unique, immutable column. [src3]Date-range chunking within lastmodifieddate windows. [src4]Always follow @odata.nextLink from response. [src5, src8]Add prompts (date ranges) to chunk, or use WQL API. [src6]# BAD -- O(n) scan + data consistency issues at high offsets
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 500K: 30+ second queries
# GOOD -- O(1) per page, consistent results
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
# BAD -- non-deterministic results; records may duplicate or disappear
params = {"limit": 500, "offset": offset}
data = requests.get(f"{url}/invoices", params=params).json()
# GOOD -- deterministic page ordering on unique column
params = {"limit": 500, "offset": offset, "orderBy": "InvoiceId:asc"}
data = requests.get(f"{url}/invoices", params=params).json()
# BAD -- silently stops at page 1,000 even with more data
while True:
data = requests.get(f"{url}?limit=100&offset={offset}").json()
all_records.extend(data["items"])
if not data["hasMore"]: break # False positive at page 1,000!
offset += 100
# GOOD -- chunk by date to stay under 1,000-page cap per window
current = start_date
while current < end_date:
chunk_end = min(current + timedelta(days=7), end_date)
records = paginate_within_cap(
f"{url}?q=lastModifiedDate BETWEEN '{current}' AND '{chunk_end}'",
page_size=1000)
all_records.extend(records)
current = chunk_end
Check this comparison table before designing integration. [src7]Use the complete nextLink URL as-is. [src5, src8]Refresh tokens proactively before expiry. [src1]Use 500-2,000 for reliable throughput. [src5]Verify retrieved count; implement date-range chunking. [src4]Use cursor where available; paginate on immutable columns otherwise. [src7]# Salesforce: Pre-check record count
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
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
curl -s -H "Authorization: Bearer $ORA_TOKEN" \
"$ORA_URL/fscmRestApi/resources/latest/invoices?limit=1&totalResults=true" \
| jq '.totalResults'
# NetSuite: Assess page cap risk
curl -s -H "Authorization: $NS_AUTH" \
"https://$NS_ACCOUNT.suitetalk.api.netsuite.com/services/rest/record/v1/customer?limit=1" \
| jq '.totalResults'
# Dynamics 365: Count entities
curl -s -H "Authorization: Bearer $D365_TOKEN" \
"$D365_URL/data/SalesOrderHeaders/\$count"
| ERP | Feature | Introduced | Status | Notes |
|---|---|---|---|---|
| Salesforce | nextRecordsUrl cursor | API v20+ (2010) | Stable | Unchanged for 15+ years |
| Salesforce | GraphQL cursors | v56.0 (2022) | GA | Alternative for complex queries |
| SAP S/4HANA | OData V4 $skiptoken | 2020 | Current | Preferred over $skip/$top |
| Oracle ERP Cloud | REST offset/limit | 2017 | Current | No cursor alternative planned |
| NetSuite | REST API pagination | 2019.2 | Current | 1,000-page cap since launch |
| Dynamics 365 | $skiptoken adoption | 2023+ | Current | Replacing $skip |
| Workday | WQL pagination | 2022 | Current | Alternative to RaaS |
| IFS Cloud | OData V4 paging | 22R1 | Current | Standard OData pattern |
| Acumatica | REST $top/$skip | 2019 R1 | Current | No cursor alternative |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Extracting <100K records for sync | Extracting >1M records for migration | Bulk API (SF), BICC (Oracle), DMF (D365) |
| Incremental data sync | Initial full data load | Bulk export APIs or file-based import |
| Feeding dashboards with fresh data | Historical analytics over full dataset | Data warehouse / BICC / Prism Analytics |
| Moderate-volume scheduled integrations | High-frequency event processing | CDC / Platform Events / webhooks |
| Capability | Salesforce | SAP S/4HANA | Oracle ERP | NetSuite | D365 | Workday | IFS | Acumatica |
|---|---|---|---|---|---|---|---|---|
| Pagination Type | Cursor | $skiptoken | Offset | Offset | $skiptoken | Page/offset | $skiptoken | $skip + keyset |
| Consistency | Strong | Strong | None | None | Strong | None | Strong | None |
| Perf at 100K | Excellent | Good | Degraded | Capped | Good | N/A | Good | Degraded |
| Perf at 1M | Good | Good | Very poor | Impossible | Good | N/A | Good | Very poor |
| Max Retrievable | Unlimited* | Unlimited | Unlimited** | ~1M | Unlimited | ~50K RaaS | Unlimited | Unlimited** |
| Resumable | No | No | Yes | Yes | No | Yes | Depends | Yes |
| Random 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.