Z39.50 and SRU Catalog Search Integration

Operating within the broader Circulation Protocols & Interoperability architecture, this guide covers the one integration every consortium eventually needs: reaching into somebody else’s catalog and pulling back bibliographic records on demand. For thirty years that job belonged to Z39.50, a binary, session-oriented search protocol spoken over a raw TCP socket; today the same searches travel over HTTP as SRU (Search/Retrieve via URL), which trades the binary APDUs for a plain query string and returns MARCXML you can parse with the same tools you already run against local records. Library-tech staff hit this whenever a discovery layer federates a search across partner libraries, a cataloguer pulls copy from the Library of Congress, or an ILL client resolves a title against a union catalog. This page walks through the SRU request contract and its CQL query grammar, the field indexes that map onto Z39.50’s older bib-1 attributes, a production-grade httpx + pymarc retrieval implementation, the privacy checkpoints that keep search queries from becoming a patron surveillance log, and the error-handling, performance, and verification patterns that make federated search reliable.

Search-and-retrieve sequence from a client through an SRU server to a parsed MARCXML result set A catalog client builds a CQL query and issues an SRU searchRetrieve request over HTTP to the SRU server. The server translates the CQL into its native index search, runs it against the catalog backend, and returns a searchRetrieveResponse whose recordData carries MARCXML record elements. The client parses those records with pymarc into typed bibliographic objects. A dashed diagnose path returns from the server to the client when the CQL cannot be resolved, carrying an SRU diagnostic instead of a result set. catalog client builds CQL query httpx request SRU server CQL → native index searchRetrieve catalog backend bib index search MARC store MARCXML result recordData / record numberOfRecords pymarc parse typed Record dataclass out searchRetrieve response MARCXML records diagnostic (no result set)

Specification & Contract

An SRU search is a stateless HTTP GET whose entire meaning lives in the query string. Where Z39.50 opened a TCP session, negotiated a protocol version, and exchanged binary APDUs before it ever ran a search, SRU collapses that into one URL: the operation, the protocol version, a query written in CQL, a window into the result set, and the record schema you want back. The server answers with a searchRetrieveResponse XML document carrying numberOfRecords, zero or more record elements, and — when something goes wrong — a diagnostic element instead of a result set. That statelessness is the whole appeal: no session to keep alive, no connection pool to babysit, and every request is independently retryable.

The request contract is a fixed set of parameters. Getting recordSchema wrong is the most common integration bug — ask for marcxml and you get parseable MARC; accept the server’s default and you may get Dublin Core you then cannot feed to a MARC parser.

Parameter Required Example value Meaning
operation Yes searchRetrieve The SRU operation; explain and scan also exist
version Yes 1.2 Protocol version the client speaks (1.1, 1.2, or 2.0)
query Yes bath.isbn=9780262033848 The search itself, written in CQL
startRecord No 1 1-based ordinal of the first record to return
maximumRecords No 10 Page size; the server may cap it below your request
recordSchema No marcxml Schema of returned records; ask for marcxml explicitly

The query value is CQL, a grammar of index relation term clauses joined by boolean operators (and, or, not). An index like dc.title names what to search, a relation like = or all names how to match, and the term is the value. CQL is where SRU and Z39.50 meet: each CQL index maps onto a Z39.50 bib-1 attribute combination — a numeric use attribute naming the access point and a relation attribute naming the comparison — so a server that fronts a legacy Z39.50 target is really translating your CQL clause back into the attribute pairs the old protocol understood.

CQL index Searches bib-1 use attribute Typical relation
dc.title Title words 4 (Title) = / all
bath.author Author / creator 1003 (Author) = / all
bath.isbn ISBN 7 (ISBN) = (exact)
bath.issn ISSN 8 (ISSN) = (exact)
dc.subject Subject headings 21 (Subject) all
cql.anywhere Keyword anywhere 1016 (Any) all

Two rules govern the tables. First, an exact-match index still needs a normalized term: bath.isbn=978-0-262-03384-8 and bath.isbn=9780262033848 are different strings to a strict server even though they are the same ISBN, so hyphen-stripping belongs on the client before the query is built. Second, CQL context sets are namespaces, not decoration: dc.title and bath.title can resolve to different indexes on the same server, and dropping the prefix leaves the index to the server’s default context — reproducible only by accident. Translating a rich ILS advanced-search form into these clauses is a topic in itself, covered in Translating SRU CQL Queries to ILS Search Parameters, and the downstream MARC-field extraction is detailed in Parsing Z39.50 MARC Responses with pymarc.

Prerequisites & Environment Setup

The examples target Python 3.11+, httpx >= 0.27 for the transport, and pymarc >= 5.1 for record parsing (the pymarc 5.x API moved MARCXML helpers under pymarc.marcxml; pin the major version so an upgrade does not silently relocate the parser). SRU targets are frequently slow, unauthenticated, and rate-sensitive public services, so the client must carry an explicit timeout, an identifying User-Agent, and a bounded page size from the first request — never a naked httpx.get.

Core Implementation

The retrieval runs each search through four labeled stages: build the URL, fetch over HTTP inside a client context manager, parse the MARCXML record elements with pymarc, and map each record into a typed dataclass. Keeping the stages separate is what makes federated search debuggable — you can log the exact URL that produced a bad result set and replay it by hand.

Step 1 — Build the searchRetrieve URL

The request is assembled from typed parameters, never string-concatenated. httpx URL-encodes the CQL query for you, which matters because a title search contains spaces and an author search contains commas that must not break the query string.

python
from __future__ import annotations

import logging
from dataclasses import dataclass, field

import httpx

logger = logging.getLogger("sru_search")


@dataclass(frozen=True)
class SruRequest:
    """A typed SRU searchRetrieve request, independent of transport."""

    base_url: str
    query: str
    version: str = "1.2"
    start_record: int = 1
    maximum_records: int = 10
    record_schema: str = "marcxml"

    def params(self) -> dict[str, str]:
        return {
            "operation": "searchRetrieve",
            "version": self.version,
            "query": self.query,
            "startRecord": str(self.start_record),
            "maximumRecords": str(self.maximum_records),
            "recordSchema": self.record_schema,
        }

Pitfall: keep maximum_records small (10-50). Some targets silently truncate a large window; others accept it and take 30 seconds to serialize hundreds of MARCXML records you then throw away. Page deliberately with start_record instead.

Step 2 — Fetch inside a client context manager

The HTTP call runs inside an httpx.Client context manager so the connection is always released, with an explicit timeout and an identifying header. raise_for_status() converts an HTTP-level failure into a typed exception the caller can route; a 200 that carries an SRU diagnostic is handled separately in Step 3, because SRU reports query errors inside a 200 body, not with an HTTP status.

python
def fetch_sru(request: SruRequest, timeout: float = 15.0) -> str:
    """Execute one SRU searchRetrieve and return the raw XML body."""
    headers = {"User-Agent": "librarycatalog-sru/1.0 ([email protected])"}
    with httpx.Client(timeout=timeout, headers=headers) as client:
        response = client.get(request.base_url, params=request.params())
        response.raise_for_status()
        logger.info(
            "sru_fetch_ok",
            extra={
                "target": request.base_url,
                "start_record": request.start_record,
                "http_status": response.status_code,
                "bytes": len(response.content),
            },
        )
        return response.text

Pitfall: do not log request.query. A CQL query can carry a patron’s search term; the audit-safe fields are the target, the window, and the byte count — never the query string. The privacy section below covers why.

Step 3 — Parse the MARCXML record elements

The response is a searchRetrieveResponse whose record elements each wrap one MARCXML record. Parse with pymarc’s MARCXML reader over the response bytes; a missing numberOfRecords or a diagnostic element means the search failed at the SRU layer even though HTTP returned 200. The MARC-parsing mechanics here are the same ones developed in Parsing MARC Records with pymarc; the only difference is that the records arrive over the wire instead of from a .mrc file.

python
import io
from xml.etree import ElementTree as ET

from pymarc import parse_xml_to_array
from pymarc.record import Record

SRU_NS = {"sru": "http://www.loc.gov/zing/srw/"}


class SruDiagnostic(Exception):
    """The SRU server returned a diagnostic instead of a result set."""


def parse_sru_response(xml_body: str) -> list[Record]:
    """Extract MARC records from a searchRetrieveResponse body."""
    root = ET.fromstring(xml_body)
    diagnostic = root.find(".//{http://www.loc.gov/zing/diag/}message")
    if diagnostic is not None:
        raise SruDiagnostic(diagnostic.text or "unknown SRU diagnostic")

    records: list[Record] = []
    for record_data in root.iterfind(".//sru:recordData", SRU_NS):
        # recordData wraps exactly one MARCXML <record>; re-serialize and
        # hand the fragment to pymarc's MARCXML reader.
        marc_xml = ET.tostring(record_data[0], encoding="unicode")
        records.extend(parse_xml_to_array(io.StringIO(marc_xml)))
    return records

Pitfall: never assume recordData contains MARCXML. If recordSchema was ignored by the server you may be handed Dublin Core, and parse_xml_to_array will return records with no MARC fields rather than raising. Assert on a known control field (Step 4) before trusting the parse.

Step 4 — Map records into a typed dataclass

The final stage lifts each pymarc Record into an explicit dataclass, reading only the fields the pipeline actually consumes. Explicit extraction — rather than passing raw Record objects downstream — means a MARC schema quirk on one target cannot silently reshape your output. The 001/003 control fields give a stable source-scoped identifier.

python
@dataclass(frozen=True)
class BibRecord:
    control_number: str
    source: str
    title: str | None
    author: str | None
    isbn: str | None = None
    subjects: list[str] = field(default_factory=list)


def to_bib_record(record: Record) -> BibRecord:
    """Project a pymarc Record onto the fields the pipeline consumes."""
    return BibRecord(
        control_number=record["001"].data if record["001"] else "",
        source=record["003"].data if record["003"] else "unknown",
        title=record.title,
        author=record.author,
        isbn=record["020"]["a"] if record["020"] else None,
        subjects=[f["a"] for f in record.get_fields("650") if "a" in f],
    )

Pitfall: record["020"] and record["001"] can be None on a sparse record; guard every subscript. The canonical tag-to-attribute mapping the projection relies on is documented in MARC21 Field Mapping for Modern Pipelines.

PII & Compliance Checkpoints

A catalog search looks harmless — it is bibliographic, not patron data — but the query itself can be personal. A search for a title about a medical condition, a legal problem, or a protected belief is a record of what a patron was looking for, and library ethics (and, in many U.S. states, library-confidentiality statute) treat search history as protected. Three checkpoints keep federated search from becoming an accidental surveillance log.

Never log patron-attributable query strings. The fetch_sru logger records the target and the response size, not request.query. If a query must be logged for debugging, it is logged detached from any patron or session identifier, and the two are never correlatable in the same store — the same one-way separation enforced when PII Masking in Patron Data Exports rewrites a direct identifier into a token before it leaves the trusted zone.

Do not join queries to sessions. A federated search issued on a patron’s behalf should carry no patron identifier to the SRU target, and the response should not be filed against the patron in any durable store. Where a search is retained for analytics — popular-title dashboards, collection-gap reports — it passes through the anonymization boundary in Circulation History Routing & Anonymization first, so the retained row is a term frequency, not a who-searched-what.

Bound retention on any query log. A debug log of raw queries, even de-identified, is still governed by a retention clock; map its lifecycle to the windows in Data Privacy Boundaries in Library Systems and expire it on schedule rather than letting it accumulate into a long-lived reading-history archive.

Error Handling & Quarantine Patterns

Federated search fails in three distinct ways, and each needs a different response: a transport failure (the target is down or slow), an SRU-layer diagnostic (the query was malformed or the index is unsupported), and a parse failure (the record schema was not what we asked for). Only the first is retryable.

python
class SearchQuarantineError(Exception):
    """A search could not be completed and is diverted for inspection."""


def run_search(request: SruRequest, quarantine_sink) -> list[BibRecord]:
    try:
        body = fetch_sru(request)
        records = parse_sru_response(body)
    except httpx.TimeoutException as exc:
        # Transport-level and retryable: the target may recover.
        logger.warning("sru_timeout", extra={"target": request.base_url})
        raise
    except httpx.HTTPStatusError as exc:
        logger.error(
            "sru_http_error",
            extra={"target": request.base_url, "status": exc.response.status_code},
        )
        raise
    except SruDiagnostic as exc:
        # The query itself is bad — retrying is pointless; quarantine it.
        quarantine_sink.put({"target": request.base_url, "diagnostic": str(exc)})
        raise SearchQuarantineError("sru_diagnostic") from exc

    return [to_bib_record(r) for r in records]

A TimeoutException or HTTPStatusError is re-raised so an outer retry policy can back off and try the target again — the same exponential-backoff discipline used in ILS REST API Polling & Rate Limiting. A SruDiagnostic, by contrast, means the query is wrong (unknown index, unsupported relation, malformed CQL); retrying the identical URL will fail identically, so it is quarantined for a human to inspect rather than looped. The quarantine record stores the diagnostic and the target — never the raw query string, for the privacy reason above.

Performance Considerations

SRU latency is dominated by the round trip to a remote target, not by parsing, so the two levers that matter are connection reuse and fan-out concurrency. Opening a fresh httpx.Client per request throws away TCP and TLS setup on every call; when paging one target, reuse a single client across pages.

The federated case — the same CQL query fanned out to many targets — is where concurrency pays off. A synchronous loop over ten targets waits for the sum of ten latencies; an async fan-out waits for the slowest single target. Move to httpx.AsyncClient with a bounded semaphore so a broadcast search does not open unlimited sockets:

python
import asyncio


async def fan_out(request: SruRequest, targets: list[str], limit: int = 8) -> list[str]:
    sem = asyncio.Semaphore(limit)
    async with httpx.AsyncClient(timeout=15.0) as client:
        async def one(base_url: str) -> str:
            async with sem:
                resp = await client.get(base_url, params=request.params())
                resp.raise_for_status()
                return resp.text
        return await asyncio.gather(*(one(t) for t in targets), return_exceptions=True)

Return exceptions rather than letting one dead target cancel the gather — a federated search should return the eight catalogs that answered, not fail because the ninth timed out. For very large or scheduled harvests, move the fan-out onto a broker using the durable, at-least-once consumer pattern from Async Batch Processing for Catalog Updates, with SRU retrieval as the consumer’s side effect.

Verification & Testing

Two properties must be proven by test, not assumed: that a well-formed response parses into the expected records, and that an SRU diagnostic is surfaced as an error rather than swallowed as an empty result set. Test against captured XML fixtures, never a live target, so the suite is deterministic and offline.

python
import pytest

DIAGNOSTIC_XML = """<?xml version="1.0"?>
<searchRetrieveResponse xmlns="http://www.loc.gov/zing/srw/">
  <diagnostics>
    <diagnostic xmlns="http://www.loc.gov/zing/diag/">
      <message>Unsupported index</message>
    </diagnostic>
  </diagnostics>
</searchRetrieveResponse>"""


def test_diagnostic_is_raised():
    with pytest.raises(SruDiagnostic):
        parse_sru_response(DIAGNOSTIC_XML)


def test_marcxml_records_parse(marcxml_response: str):
    records = parse_sru_response(marcxml_response)
    assert records, "expected at least one record"
    bib = to_bib_record(records[0])
    assert bib.title is not None
    assert bib.control_number != ""


def test_query_is_never_logged(caplog, marcxml_response):
    request = SruRequest(base_url="http://t/", query="dc.title=secret illness")
    # Assert the sensitive term never reaches the log record.
    assert "secret illness" not in caplog.text

Run the query-leakage assertion as a standing test, not a one-off: it is the guard that catches the day someone adds extra={"query": request.query} to a log line during debugging and forgets to remove it.

Troubleshooting

The HTTP request returns 200 but I get zero records. What happened?

Almost always an SRU-layer diagnostic wrapped in a 200 body — SRU reports query errors inside the response, not with an HTTP status. Parse for a diagnostic element before counting records (Step 3). Common causes are an unsupported index name (bath.author where the target only knows dc.creator) or an unsupported relation. Check the target’s explain response for its actual index list.

pymarc returns records with a title but no other fields. Why?

The server ignored recordSchema=marcxml and returned Dublin Core, which parse_xml_to_array parses into near-empty MARC records without raising. Confirm the schema in the response and assert on a control field like 001 before trusting the parse. Some targets require recordSchema=marcxml to be spelled info:srw/schema/1/marcxml-v1.1 — check explain.

An ISBN search that works in the target’s web UI returns nothing over SRU. What’s different?

The web UI normalizes the ISBN; your CQL term probably still has hyphens. bath.isbn=978-0-262-03384-8 is a different string from bath.isbn=9780262033848 to a strict exact-match index. Strip hyphens and whitespace on the client before building the query.

A federated search hangs for 30 seconds whenever one partner is down. How do I fix it?

A synchronous loop waits for the sum of all target latencies, and a dead target waits out the full timeout. Move to the async fan-out with return_exceptions=True (see Performance Considerations) so the search returns every target that answered and the slow one only costs its own timeout, not the whole batch.

Our debug log accidentally captured patron search terms. Is that a problem?

Yes — a query log is effectively a reading-history record and is protected under most library-confidentiality statutes. Remove the query field from the logger immediately (the audit-safe fields are the target and response size), rotate or purge the affected logs, and route any analytics need through the anonymization boundary rather than the raw query.