Skip to main content

snowloader

snowloader

Get ServiceNow data into your AI pipeline without quietly losing rows.

Incidents, Knowledge Base, CMDB, Changes, Problems, Catalog and attachments,
into LangChain, LlamaIndex, or your own code. Every sweep sorts on a unique key,
so offset pagination cannot drop records, and it can prove it returned everything.

PyPI version Python versions CI Tests Typed MIT license

Documentation  ·  PyPI  ·  Install  ·  Upgrading  ·  API  ·  Roadmap

Offset pagination over a non-unique sort column returns some rows twice and skips others

Every release before 0.3.0 paged over a column that is not unique. The count still reconciled, so nothing reported it.

9
loaders, each with
an async variant
3
pagination paths:
sequential, threaded, async
4
authentication
modes
346
unit tests, plus 21
against a live instance

In three lines

from snowloader import SnowConnection, IncidentLoader

with SnowConnection(instance_url="https://yourcompany.service-now.com",
                    username="api_user", password="api_pass") as conn:
    docs = IncidentLoader(connection=conn, query="active=true").load()

Three lines from a ServiceNow instance to a list of documents your vector store understands. The same loader objects work with LangChain, LlamaIndex, or anything else that accepts a list of dicts.


What 0.3.0 fixes

ServiceNow does not guarantee a stable row order inside a group of rows that tie on the sort column. Every release before 0.3.0 paged over sys_created_on, which is not unique, so a page boundary landing inside a tied group returned some rows twice and skipped others.

The returned count still matched, so nothing reported a problem. The loss was identical on every run, so re-running never revealed it. Measured on a developer instance where cmdb_ci holds 2,919 rows across only 818 distinct timestamps, three consecutive sweeps each returned 2,919 rows and only 2,915 of them were distinct.

Every ORDERBY chain now ends in sys_id, and you can ask a sweep to prove it was complete:

records = list(conn.get_records("cmdb_ci", verify=True))   # raises if anything is missing

The second thing it fixes is that a document could not be joined to anything. The loaders returned labels and threw the identifiers away.

Incident metadata before and after 0.3.0

Details in the upgrade notes.


Upgrading to 0.3.0

0.3.0 fixes a data loss bug. If you are on 0.2.x, upgrade.

ServiceNow offset pagination is only safe over a unique sort key. The API does not guarantee a stable order inside a group of rows that tie on the sort column, so a page boundary landing inside a tied group returns some rows twice and skips others. Every release before 0.3.0 sorted on sys_created_on, which is not unique.

Measured on a developer instance, cmdb_ci holds 2,919 rows across only 818 distinct sys_created_on values, worst tie 31 rows. Three consecutive sweeps:

run 1: returned=2919 distinct=2915 lost=4 duplicated=4 count_matches_api=True
run 2: returned=2919 distinct=2915 lost=4 duplicated=4 count_matches_api=True
run 3: returned=2919 distinct=2915 lost=4 duplicated=4 count_matches_api=True

The count reconciles, so nothing reports a problem. The loss is deterministic, so re-running never surfaces it and diffing two runs shows nothing. On a 30,000 row CMDB that rate is roughly 40 missing CIs.

0.3.0 ends every ORDERBY chain with sys_id. Nothing in your code changes. The same table now returns all 2,919 distinct rows on both the sequential and the threaded path.

You can also ask a sweep to prove it was complete:

records = list(conn.get_records("cmdb_ci", verify=True))   # raises if anything is missing

Two breaking changes come with it, both in loader metadata. The one to check before you upgrade a retrieval pipeline: metadata now carries every field on the record plus an identifier beside each reference, which on a wide table is roughly three times the previous size. If you write the whole metadata dict into a vector store with a per-vector size limit, pass expand_references=False to keep the 0.2.x shape. Full detail in the changelog and the docs.


Architecture

snowloader data flow

snowloader sits between ServiceNow's Table API and whatever LLM stack you are building. The connection layer handles auth, pagination, retries, and rate limiting. The loaders normalize each table into a SnowDocument. The adapters translate that into LangChain Document or LlamaIndex Document types without copying business logic.


Why snowloader?

Building RAG or agentic AI on top of ServiceNow data. snowloader covers the core tables plus a generic loader for the rest, gives you sync, threaded and async paginators, and keeps the core free of any framework so you can plug it into LangChain, LlamaIndex, or your own pipeline.

Sweeps that do not lose rows

Every paginated read sorts on a unique key, so an offset page boundary landing inside a tied timestamp cannot silently drop records.

Sweeps that prove it

verify=True counts the table first and raises rather than handing back an incomplete extract that looks fine.

Nine loaders

Incidents, Knowledge Base, CMDB, Changes, Problems, Catalog, Attachments, CI relationships, and a generic TableLoader for anything else.

Three pagination paths

Sequential get_records, threaded concurrent_get_records, async aget_records. Pick the one that fits your runtime.

Four auth modes

Basic, OAuth Password, OAuth Client Credentials, Bearer Token. Switching is a constructor argument.

Both halves of every field

The readable label and the sys_id you join on, side by side in metadata. No helper to write yourself.

Delta sync

load_since(datetime) on every loader. Only fetch what changed since your last run.

CMDB graph walking

Sweep cmdb_rel_ci once for every edge, or traverse per CI when you only need a few.

Streaming everywhere

Generators and async iterators throughout. The full table never lives in memory at once.

Built-in HTML cleaner

KB articles arrive as plain text. No BeautifulSoup, no extra dependencies.

Tested against a live instance

Retry with backoff, rate limiting, thread-safe sessions, proxy support, custom CA bundles.

Strict typing

PEP 561 marker, mypy --strict clean, full type hints on every public surface.

Installation

# pip
pip install snowloader              # Core only
pip install snowloader[async]       # + AsyncSnowConnection (aiohttp)
pip install snowloader[langchain]   # + LangChain adapter
pip install snowloader[llamaindex]  # + LlamaIndex adapter
pip install snowloader[all]         # Everything

# uv
uv add snowloader
uv add snowloader[all]

Requirements: Python 3.10, 3.11, 3.12, or 3.13. A ServiceNow instance with REST Table API access.


API cheatsheet

Loader ServiceNow table Highlight
IncidentLoader incident Optional journal entries (work notes + comments)
KnowledgeBaseLoader kb_knowledge HTML auto-stripped, plain text out
CMDBLoader cmdb_ci_* Concurrent relationship graph traversal
ChangeLoader change_request Implementation window details
ProblemLoader problem Known error flag normalized to bool
CatalogLoader sc_cat_item Active / inactive normalized to bool
AttachmentLoader sys_attachment Optional eager download with size cap
RelationshipLoader cmdb_rel_ci One document per edge, both endpoints as sys_ids
TableLoader anything Generic loader for tables with no dedicated one

Every loader exposes the same interface:

loader.load()                         # list[SnowDocument]
loader.lazy_load()                    # generator
loader.load_since(datetime_cutoff)    # list[SnowDocument]
loader.concurrent_load(max_workers)   # threaded
loader.concurrent_lazy_load(...)      # threaded generator

loader.load(verify=True)              # raises if the sweep lost records
loader.load(on_error="skip")          # finish past a dead page instead of aborting

Async siblings (when installed with [async]) follow the same shape: aload, alazy_load, aload_since.

Document metadata carries both halves of every reference field, so a record can be joined to what it points at:

doc.metadata["assignment_group"]         # 'Service Desk'
doc.metadata["assignment_group_sys_id"]  # 'd625dcce...'
doc.metadata["priority"]                 # '5 - Planning'
doc.metadata["priority_value"]           # '5'

Pick the right pagination path

API decision tree

Three concurrency models, three jobs. The numbers below came out of a run against a developer instance, not an estimate. Reproduce them on your own instance with scripts/benchmark_v030.py.

Threaded sweep timings by worker count

Two things worth taking from that chart. Throughput peaks at 16 workers, which is why 16 is the default, and 32 workers came out slower than 16 rather than marginally faster. Page size in the low hundreds measured best on this path; raising it does not buy speed here, because throughput is bounded by how many pages are in flight rather than by request count.

The async path pulls the other way and wants larger pages, which is why its default is 500. Do not carry a page size from one path to the other.

The threaded path uses a per-thread requests.Session, which keeps connection pools and TLS state isolated per worker and avoids the connection-reuse failures some ServiceNow front ends exhibit when many concurrent requests share one session.


Code recipes

A sweep that proves it was complete
from snowloader import SnowConnection, SweepIncompleteError

with SnowConnection(
    instance_url="https://yourcompany.service-now.com",
    username="api_user",
    password="api_pass",
    page_size=100,          # smaller pages parallelise better
    order_by="sys_id",      # unique key, and the cheapest sort
) as conn:
    try:
        records = list(
            conn.concurrent_get_records(
                "cmdb_ci",
                max_workers=16,
                verify=True,        # free here: the count is already fetched
                on_error="skip",    # finish the run, then complain
            )
        )
    except SweepIncompleteError as exc:
        alert(f"CMDB extract incomplete: {exc.report}")
        # table=cmdb_ci expected=30000 returned=29900 distinct=29900
        # missing=100 duplicated=0 failed_pages=1
        raise

verify=True counts the table before reading it and raises if the sweep did not return that many distinct records. on_error="skip" logs a page that could not be fetched after its retries were exhausted, leaves a gap and lets the rest finish, so an unattended run completes and then tells you what it lost instead of dying on page nine hundred.

CMDB as a graph, in two sweeps
from snowloader import SnowConnection, TableLoader, RelationshipLoader

with SnowConnection(
    instance_url="https://yourcompany.service-now.com",
    username="api_user",
    password="api_pass",
    display_value="all",
    order_by="sys_id",
) as conn:
    for doc in TableLoader(conn, table="cmdb_ci").lazy_load(verify=True):
        graph.add_node(
            doc.metadata["sys_id"],
            name=doc.metadata.get("name"),
            # the value half, so this is the table name the CI lives in,
            # not the pretty label
            ci_class=doc.metadata.get("sys_class_name_value"),
        )

    for doc in RelationshipLoader(conn).lazy_load(verify=True):
        graph.add_edge(
            doc.metadata["parent_sys_id"],
            doc.metadata["child_sys_id"],
            type=doc.metadata["type"],
        )

Both endpoints and the relationship type come back as identifiers, so the edges load with no resolution step. CMDBLoader(include_relationships=True) is still there for walking a handful of CIs, but it costs two extra requests per CI. Measured on a developer instance that came to 2.4 seconds per CI, which projects to about 34 hours for a 50,000 CI estate. Sweeping the whole relationship table on the same instance took 7 seconds.

Sequential extraction (the simplest path)
from snowloader import SnowConnection, IncidentLoader

with SnowConnection(
    instance_url="https://yourcompany.service-now.com",
    username="api_user",
    password="api_pass",
) as conn:
    loader = IncidentLoader(connection=conn, query="active=true^priority<=2")
    for doc in loader.lazy_load():
        process(doc)
Threaded extraction (sync, fast)
from snowloader import SnowConnection, IncidentLoader

with SnowConnection(
    instance_url="https://yourcompany.service-now.com",
    username="api_user",
    password="api_pass",
    page_size=100,
) as conn:
    total = conn.get_count("incident", query="state=6^close_notesISNOTEMPTY")

    for record in conn.concurrent_get_records(
        table="incident",
        query="state=6^close_notesISNOTEMPTY",
        max_workers=16,
    ):
        process(record)

    loader = IncidentLoader(connection=conn, query="state=6^close_notesISNOTEMPTY")
    docs = loader.concurrent_load(max_workers=16)
Async extraction (asyncio apps)
import asyncio
from snowloader import AsyncSnowConnection, AsyncIncidentLoader

async def main() -> None:
    async with AsyncSnowConnection(
        instance_url="https://yourcompany.service-now.com",
        username="api_user",
        password="api_pass",
        page_size=500,
        concurrency=16,
    ) as conn:
        loader = AsyncIncidentLoader(connection=conn, query="active=true")
        async for doc in loader.alazy_load():
            print(doc.page_content[:200])

asyncio.run(main())

Every sync loader has a matching Async* variant. The framework adapters expose async forms too (AsyncServiceNow*Loader for LangChain, AsyncServiceNow*Reader for LlamaIndex).

LangChain adapter
from snowloader import SnowConnection
from snowloader.adapters.langchain import ServiceNowIncidentLoader

conn = SnowConnection(
    instance_url="https://yourcompany.service-now.com",
    username="api_user",
    password="api_pass",
)
loader = ServiceNowIncidentLoader(connection=conn, query="active=true")
docs = loader.load()  # list[langchain_core.documents.Document]

# Plug straight into any vector store
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

vectorstore = FAISS.from_documents(docs, OpenAIEmbeddings())
LlamaIndex adapter
from snowloader.adapters.llamaindex import ServiceNowIncidentReader

reader = ServiceNowIncidentReader(connection=conn, query="active=true")
docs = reader.load_data()  # list[llama_index.core.schema.Document]

from llama_index.core import VectorStoreIndex
index = VectorStoreIndex.from_documents(docs)
Delta sync
from datetime import datetime, timezone

loader = IncidentLoader(connection=conn)
docs = loader.load()                          # First run: everything
last_sync = datetime.now(timezone.utc)

updated = loader.load_since(last_sync)        # Subsequent runs: only changes
CMDB relationship graph
from snowloader import CMDBLoader

loader = CMDBLoader(
    connection=conn,
    ci_class="cmdb_ci_server",
    include_relationships=True,
)

for doc in loader.lazy_load():
    # -> db-prod-01 (Depends on::Used by)
    # <- load-balancer-01 (Depends on::Used by)
    print(doc.page_content)
Journal entries (work notes + comments)
loader = IncidentLoader(connection=conn, query="active=true", include_journals=True)
for doc in loader.lazy_load():
    print(doc.page_content)
    # Incident: INC0000007
    # Summary: Need access to sales DB
    # ...
    # [work_notes] 2024-06-01 09:15:00 by alice
    # Restarted Exchange service, monitoring.

Also works with ChangeLoader and ProblemLoader.

Attachments
from snowloader import AttachmentLoader

# Metadata only
loader = AttachmentLoader(connection=conn, query="table_name=kb_knowledge")
for doc in loader.lazy_load():
    print(doc.metadata["file_name"], doc.metadata["size_bytes"])

# Download a specific file by sys_id
loader.download_to("att_sys_id", "./out/diagram.png")

# Eager download with size cap (10 MB)
loader = AttachmentLoader(connection=conn, download=True, max_size_bytes=10 * 1024 * 1024)
for doc in loader.lazy_load():
    blob = doc.metadata.get("content_bytes")
Authentication (4 modes)
# Basic Auth (development)
conn = SnowConnection(instance_url="...", username="admin", password="pass")

# OAuth Client Credentials (recommended for production)
conn = SnowConnection(instance_url="...", client_id="...", client_secret="...")

# OAuth Password Grant
conn = SnowConnection(instance_url="...", client_id="...", client_secret="...",
                       username="...", password="...")

# Bearer Token (pre-obtained)
conn = SnowConnection(instance_url="...", token="eyJhbG...")
Recipe: large-scale extraction with resume support

A common pattern for AI knowledge bases is two parallel corpus pulls. Closed and resolved tickets become a recommendation corpus; active tickets become a duplicate-prevention corpus. Both need raw API output (with sysparm_display_value=all), JSONL streaming, resume on crash, and end-of-run validation against the API count.

import json
from pathlib import Path
from snowloader import SnowConnection

QUERY = (
    "stateIN6,7"
    "^close_notesISNOTEMPTY"
    "^sys_updated_on>=javascript:gs.daysAgoStart(730)"
    "^ORDERBYsys_created_on"
)
FIELDS = ["sys_id", "number", "short_description", "close_notes",
          "state", "priority", "urgency", "impact", "category",
          "assignment_group", "caller_id", "assigned_to",
          "opened_at", "resolved_at", "sys_updated_on"]

output_path = Path("incidents_closed.jsonl")
state_path = Path("incidents_closed.state.json")
state = json.loads(state_path.read_text()) if state_path.exists() else {"completed": []}
completed_offsets = set(state["completed"])

with SnowConnection(
    instance_url="https://yourcompany.service-now.com",
    username="api_user",
    password="api_pass",
    page_size=100,
    display_value="all",
    max_retries=5,
) as conn:
    mode = "a" if completed_offsets else "w"
    with output_path.open(mode, encoding="utf-8") as fh:
        for record in conn.concurrent_get_records(
            table="incident", query=QUERY, fields=FIELDS, max_workers=16
        ):
            sid = record["sys_id"].get("value") if isinstance(record["sys_id"], dict) else record["sys_id"]
            num = record["number"].get("value") if isinstance(record["number"], dict) else record["number"]
            if not sid or not num:
                continue
            fh.write(json.dumps(record, ensure_ascii=False) + "\n")

    line_count = sum(1 for _ in output_path.open("r"))
    api_total = conn.get_count("incident", query=QUERY)
    print(f"file: {line_count}, api: {api_total}, drift: {line_count - api_total}")

For the full pattern with offset-level checkpointing (so a crash mid-run loses at most a few seconds of work), see the concurrent documentation page.


Configuration

Parameter Default Description
page_size 100 Records per API call (1 - 10,000)
timeout 60 HTTP timeout in seconds
max_retries 3 Retry attempts for 429 / 500 / 502 / 503 / 504
retry_backoff 1.0 Base delay between retries (doubles each attempt)
request_delay 0.0 Minimum seconds between requests (rate limiting)
display_value "true" sysparm_display_value setting (true / false / all)
order_by "sys_created_on" Sort column, or list of columns. sys_id appended as a unique tiebreak. None disables ordering
since_field "sys_updated_on" Column a delta sync compares its cutoff against
proxy None HTTP / HTTPS proxy URL
verify True SSL verification (or path to a custom CA bundle)

AsyncSnowConnection takes the same arguments plus concurrency (default 16) and keep_alive (default False, which trades a TLS handshake per request for immunity to the empty-body responses some ServiceNow front ends return on reused connections under load). Measure both against your own instance before turning it on.

See the full documentation for every parameter.


Roadmap

Version Feature Status
v0.1 Six sync loaders, LangChain + LlamaIndex adapters, 4 auth modes, delta sync, journal entries, HTML cleaning, CMDB graph traversal Shipped
v0.2 Async support (aiohttp) and async variants of every loader and adapter Shipped
v0.2 Attachment loader for sys_attachment with optional eager download and size cap Shipped
v0.2 Threaded sync paginator (concurrent_get_records, concurrent_load) with per-thread sessions Shipped
v0.2 parse_labelled_int helper for fields like priority, urgency, impact Shipped
v0.3 Deterministic pagination: every ORDERBY chain ends in sys_id, so offset paging cannot lose rows to a tied sort column Shipped
v0.3 Sweep verification (verify=True, SweepReport, SweepIncompleteError) and a partial failure policy (on_error="skip") Shipped
v0.3 Reference fields as both halves everywhere, plus public field helpers (reference, raw_value, expand_reference_keys) Shipped
v0.3 Generic TableLoader, RelationshipLoader, aconcurrent_get_records, configurable order_by and since_field Shipped
v0.4 Keyset pagination and checkpoint / resume for very large loads Planned
v0.4 Direct vector store streaming (Pinecone, Weaviate, Chroma, Qdrant) Planned
v1.0 Custom field mapping for heavily customized instances Planned

Write support stays out of scope. A read-only guarantee is the reason somebody points this at production without raising a change, and a read library that starts writing has to grow opinions about Data Policies, choice lists and business rules or it becomes a polite way to corrupt a CMDB. If it ever happens it will be a sibling package sharing connection, auth and retry.


Contributing

Contributions are welcome.

  1. Fork the repository
  2. Create a feature branch
  3. Write tests first (the project uses pytest + responses for HTTP mocking)
  4. Ensure the quality gate passes:
    ruff check src/ tests/ && ruff format --check src/ tests/ && mypy src/snowloader/ && pytest tests/ -x
    
  5. Open a pull request

Author

Roni Das
thetotaltechnology@gmail.com
github.com/ronidas39
Built snowloader because every ServiceNow + AI project I picked up started with the same boilerplate. The library is the version of that boilerplate I want every team to be able to start from.

License

MIT. See LICENSE for the full text.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

snowloader-0.3.1.tar.gz (978.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

snowloader-0.3.1-py3-none-any.whl (76.7 kB view details)

Uploaded Python 3

File details

Details for the file snowloader-0.3.1.tar.gz.

File metadata

  • Download URL: snowloader-0.3.1.tar.gz
  • Upload date:
  • Size: 978.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for snowloader-0.3.1.tar.gz
Algorithm Hash digest
SHA256 1740ea7528749ca5b68b50d9e30c248ea353943bfdb0791026a35d65462da099
MD5 943116e2a00272e4daf568a583df4ecb
BLAKE2b-256 1d9f3f393610f0808bf9d3d00b8598f30bfc5b30e22b5b0c6a2046b0c9a2275b

See more details on using hashes here.

Provenance

The following attestation bundles were made for snowloader-0.3.1.tar.gz:

Publisher: publish.yml on ronidas39/snowloader

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file snowloader-0.3.1-py3-none-any.whl.

File metadata

  • Download URL: snowloader-0.3.1-py3-none-any.whl
  • Upload date:
  • Size: 76.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for snowloader-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 b5cf3a540621b89738c59df49c9677b72b96c87753e9b4492f133a78b6fddad6
MD5 a8e520341e38bc7650e28ab720e06223
BLAKE2b-256 0f0e8e6a28c157a39f30b432545ffc70179ba6f1de2738279e8caf023575bb13

See more details on using hashes here.

Provenance

The following attestation bundles were made for snowloader-0.3.1-py3-none-any.whl:

Publisher: publish.yml on ronidas39/snowloader

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.6.2

2 files

0.6.1

2 files

0.6.0

2 files

0.5.1

2 files

0.5.0

2 files

0.4.0

2 files

This release

0.3.1 This release

2 files

0.3.0

2 files

0.2.8

2 files

0.2.7

2 files

0.2.6

2 files

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page