Skip to content

Change polling, ranges & pagination

How to harvest changed and deleted records over time, and how Sierra's range filters and pagination actually behave. Most of the catalog leans toward patron-record writes; this page is the read/harvest side, gathered from running an incremental bib harvest against a production catalog. The behaviors are general to any list/range endpoint.

A zero-match range query can return 404 Record not found — and not every query shape agrees

Behavior: A range query that matches no records does not reliably return an empty list. On a production deployment, GET bibs?deleted=false&updatedDate=[<window with no changes>] returns HTTP 404 with {"code": 107, ... "name": "Record not found"}. But the deleted variant of the same shape — GET bibs?deleted=true&deletedDate=[<window with no deletions>] — returns HTTP 200 with {"start": 0, "entries": []}. Same resource, same "zero results," two different status codes.

Type: Bug-or-quirk (404-on-empty is a long-standing Sierra trait; the inconsistency between query shapes is the trap).

How to handle: Treat 404 + code 107 as "zero results," not an error, for a range/list poll — otherwise an idle window (no changes, or, more commonly, no deletions) crashes the harvest. Note that sierra-ils-utils returns 4xx responses verbatim (it does not raise on them), so you check response.status_code yourself rather than relying on raise_for_status(). And don't assume uniformity — probe the specific query shape you depend on.

resp = client.request("GET", url)
if resp.status_code == 404:        # Sierra's "no records match" for a range query
    entries = []
else:
    resp.raise_for_status()
    entries = resp.json().get("entries", [])

How we know: Read-only probes against a production catalog using far-future date windows guaranteed to match nothing: the updatedDate / deleted=false poll returned 404 code 107; the deletedDate / deleted=true poll returned 200 {"start":0,"entries":[]}.

deletedDate is date-only; updatedDate is a full timestamp

Behavior: updatedDate is a second-granularity datetime ('2021-10-07T13:52:27Z'), but deletedDate is date-only ('2023-11-30') — no time component, and the time zone is unspecified.

Type: By design (an undocumented granularity difference between the two clocks).

How to handle: A deletion-tracking cursor must be day-granular, with a ≥1-day overlap re-sweep — a sub-day "lag" is meaningless against a date-only field. Expect to re-see a freshly deleted id on later polls until the cursor advances past its whole deletedDate day, so make the downstream action (e.g. tombstoning) idempotent and the re-sweep is free. Because the zone is unspecified, a record deleted near midnight can land a calendar day either side of your "today"; the overlap absorbs that too.

How we know: Probing deleted=true&deletedDate=[…] on a production catalog returned date-only deletedDate values on every entry, alongside full-timestamp updatedDate values from the same records' change history.

GET bibs returns entries ascending by id (so you can keyset-paginate)

Behavior: GET bibs returns its entries sorted ascending by id, and an id=[<n>,] lower-bound filter starts exactly at n. Successive pages requested as id=[<last_id + 1>,] come back strictly after the previous page, with no gap and no overlap.

Type: By design (the ordering is relied upon, but confirm it on your deployment).

How to handle: Prefer keyset (seek) paginationid=[<last_seen_id + 1>,] — over offset for large or long-running sweeps. Offset pagination can drop or duplicate rows if the underlying set changes mid-sweep; an ascending-id seek cursor is gap/dup-free under concurrent inserts and deletes. For the full polling loop, see Poll for changed and deleted records.

How we know: Page 1 (id=[1000000,], limit 50) returned 1000001…1000051 ascending; page 2 (id=[1000052,]) returned 1000052… — strictly after page 1, with no gap or overlap.

Range filters AND together: updatedDate + id in one query

Behavior: A time-range filter and an id-range filter can be combined in a single query, and they are ANDed: GET bibs?updatedDate=[<since>,<until>]&id=[<cursor>,] returns records satisfying both.

Type: By design.

How to handle: Combine them for windowed keyset pagination — page through a change window by ascending id with no offset, getting both the late-arriving-data safety of a time window and the gap/dup-free property of a seek cursor.

How we know: updatedDate=[2020-…,2030-…]&id=[1000000,] returned 200 with 50 ascending entries, each carrying a populated updatedDate.

List responses are capped (~2000); detect the end by a short page, not by total

Behavior: A list/range GET returns at most ~2000 entries per request, and the total field reflects the capped count for that request rather than the true size of the matching set. Trusting total to decide "am I done?" stops you early.

Type: By design.

How to handle: Page until you receive a page shorter than your requested limit — that short page is the end-of-results signal. Don't compare against total.

limit = 2000
offset = 0  # or a keyset cursor: id=[last_seen + 1,]
while True:
    page = fetch(offset, limit)
    yield from page
    if len(page) < limit:          # short page => done
        break
    offset += limit

How we know: A production bib harvest paginating by request limit terminates correctly on the first short page, whereas a total-based stop condition truncated long sweeps.

deleted=false silently hides server-deleted records — they vanish from incremental polls

Behavior: When a record is deleted in Sierra its deleted flag flips to true, so it drops out of every deleted=false query. A high-water-mark incremental poll filtered on deleted=false therefore never learns the record is gone — it simply stops appearing, indistinguishable from "unchanged."

Type: By design (but a silent data-integrity footgun for anyone caching or mirroring Sierra).

How to handle: A deleted=false change poll cannot track deletions. Run a separate deleted=true&deletedDate=[…] poll on its own cursor (mind its day granularity, above), and/or a periodic id reconciliation (compare your live id set against Sierra's) to catch the deletions the change poll structurally cannot see. Don't try to infer deletions from "an id I expected but didn't get back" in a change poll — that false-positives on suppressed / no-MARC records. The Poll for changed and deleted records recipe runs the required two-cursor pattern.

How we know: A mirror built only on deleted=false incremental polls accrued server-deleted records as permanently-live rows; adding a deletedDate delete-poll plus an id reconcile was required to converge.

fields=, is rejected on a deleted=true query — the delete poll must name its fields

Behavior: The fields=, "give me everything" form works on ordinary reads but returns 500 on a deleted=true query. Naming the fields explicitly succeeds immediately:

GET bibs?fields=,&deleted=true                  -> 500
GET bibs?fields=id,deletedDate&deleted=true     -> 200
   {"id": "1000042", "deletedDate": "2016-01-21"}

This compounds the omission documented in Reads & IDs → fields=, returns all fields: deletedDate is already absent from a fields=, response, so the convenient form is both incomplete for deletes and fatal on the query shape where you'd want it. There is no fields=, path to delete data.

Type: Bug-or-quirk (a 500 is a server fault, not a validation refusal — a 400 would at least name the problem).

How to handle: Write the delete poll with an explicit, minimal projection — fields=id,deletedDate is all a tombstone cursor needs. Never reach for fields=, on the deleted channel, and don't let a shared request helper apply it there by default. Because the failure is a 500 rather than a 400, a retry wrapper will happily burn its whole retry budget on it; make sure the delete poll surfaces the error instead of silently exhausting retries and reporting an empty day.

How we know: Probed against sierra-test 2026-07-27 while designing a bib delete channel: fields=,&deleted=true returned 500 on every attempt including the final one, while fields=id,deletedDate&deleted=true returned 200 with 100 entries in 0.11 s.

bibs/marc is a two-phase binary export — and id ranges keyset-paginate it

Behavior: bibs/marc does not return JSON entries like GET bibs. It is a two-phase export: the first GET bibs/marc?id=[<start>,<end>]&limit=<n> returns a small MarcSummary JSON pointing at a server-generated binary .mrc file; you then GET that file (raw ISO-2709 MARC) and DELETE it to clean up. Crucially, the id=[<cursor>,<max>]&limit=2000 form returns the lowest ~2000 ids in the range, as a contiguous ascending block — so the same keyset cursor that paginates GET bibs (see "GET bibs returns entries ascending by id" above) drives a whole-catalog MARC export with no gaps and no overlap. The limit caps at ~2000 here, identical to the list-endpoint cap documented above.

// phase 1 response — the MarcSummary, NOT the records themselves
{ "file": ".../v6/bibs/marc/files/<fileId>", "inputRecords": 2000, "outputRecords": 2000, "errors": 0 }

Type: By design (the two-phase file protocol and the lowest-first range ordering are both undocumented; confirm the ordering on your deployment before trusting max(id)+1).

How to handle: Sweep with a keyset cursor and advance by max(returned id) + 1, exactly as for GET bibs. Always DELETE the generated file each page — they accumulate server-side otherwise. Stop on a short/empty page, not on a count. For the full recipe (missing-id discovery, the GET/GET/DELETE dance, throughput, and orphan handling) see the Bulk-export the full MARC catalog how-to.

Do not assume a 200 on both phases means you received everything the bib holds — records over 99,999 bytes are truncated, and the errors counter in that MarcSummary is the only thing that says so. See MARC export.

cursor, MAX = lowest_id, 9_999_999
while work_remains:
    summary = client.request("GET", f"bibs/marc?id=[{cursor},{MAX}]&limit=2000").json()
    file_id = summary["file"].rsplit("/", 1)[-1]
    mrc = client.request("GET", f"bibs/marc/files/{file_id}").content   # binary ISO-2709
    client.request("DELETE", f"bibs/marc/files/{file_id}")              # clean up
    ids = [bib_id_of(r) for r in parse_marc(mrc)]
    if not ids:
        break
    cursor = max(ids) + 1                                               # contiguous, gap/dup-free

How we know: A production backfill swept the bib band 1,126,231 → 1,482,667 in 179 pages of ~2000 records. Every one of the 5,000 targeted ids in that band came back — 0 skipped — which is only possible if each page is the lowest-id contiguous block of the remaining range. Sustained throughput was ~900 records/sec single-threaded (≈55k/min); see the enumerated-list-cap entry in Reads & IDs for why this beats id-list batching by ~500×.