spark-rest-api-reader
Full documentation: https://liam-mcelhaney122.github.io/spark-rest-api-reader/
A generic Spark Python Data Source for pulling data out of any GET REST API — returning JSON, CSV, or XML — and into a DataFrame, batch or streaming, without writing a bespoke connector for every API.
Requires Spark 4.0+ / Databricks Runtime 15.4+ (the Python Data Source API). Not tied to Databricks otherwise — it works with any Spark 4.0+ cluster.
Why
Every project ends up writing the same HTTP-get + pagination + retry +
auth-header boilerplate to land a REST API into Bronze. This wraps that
boilerplate once, behind Spark options, so pulling a new API is a matter of
configuring auth_type/pagination_type, not writing new Python.
Built on httpx for the HTTP client and
tenacity for retry/backoff.
Install
pip install spark-rest-api-reader
On a Databricks cluster, install it as a cluster library (PyPI source:
spark-rest-api-reader) or per-notebook:
%pip install spark-rest-api-reader
For air-gapped clusters, build the wheel yourself and upload it as a cluster library:
pip install build
python -m build # produces dist/spark_rest_api_reader-*.whl
pyspark/pyarrow are intentionally not pinned as install dependencies —
the cluster already provides them, and pinning risks conflicting with the
runtime's own version. For local development/tests, install .[dev].
Quick start
from pyspark.sql.types import IntegerType, StringType, StructField, StructType
from spark_rest_api_reader import RestApiDataSource
spark.dataSource.register(RestApiDataSource)
items_schema = StructType(
[
StructField("id", IntegerType()),
StructField("name", StringType()),
StructField("status", StringType()),
]
)
df = (
spark.read.format("rest_api")
.schema(items_schema)
.option("url", "https://api.example.com/v1/items")
.option("auth_type", "bearer")
.option("token", dbutils.secrets.get("api-scope", "api-token"))
.option("pagination_type", "page_number")
.load()
)
df.write.format("delta").mode("append").saveAsTable("main.bronze.items_raw")
spark.dataSource.register only needs to run once per session (e.g. once at
the top of a job/notebook).
Secrets: resolve dbutils.secrets.get(...) (or any other secret lookup)
on the driver before passing the value into .option(...). Options are
plain strings that get shipped to executors as part of the reader — do not
put a dbutils call anywhere reachable from executor code, since dbutils
isn't available there.
How it's organized
- Auth (
auth_type) — how the request is authenticated. - Pagination (
pagination_type) — how a single partition walks through multiple pages of results until the API says "no more." - Partitioning (
partitions/pages) — how the overall pull is split into independent units of work that Spark can fan out across executors. - Response format (
response_format) — how the response body is parsed:json(default),csv, orxml. Everything downstream (records extraction, schemas, pagination stop-conditions) works the same on all three. - Records extraction (
records_path,flatten) — how to find the list of records inside the parsed response body, and whether to flatten nested objects into dotted column names. - Schema — supply a
StructTypevia.schema(...)(or theschemaoption), or setinfer_schema=trueto sample and infer one, Auto Loader-style. - Streaming — an incremental variant driven by a cursor field in the
response (e.g.
updated_at), viaspark.readStream.
Partitioning and pagination are independent: e.g. you can partition by
region (partitions) and have each region's partition separately paginate
through everything on its own (pagination_type), or auto-generate one
partition per page number (pages) for maximum parallelism when the API
doesn't need per-partition pagination at all.
Option reference
Request basics
| Option | Default | Description |
|---|---|---|
url |
required | Base URL of the GET endpoint. |
headers |
{} |
JSON object of static request headers. |
query_params |
{} |
JSON object of static query-string params sent on every request. |
timeout |
30 |
Per-request timeout in seconds. |
max_retries |
3 |
Retries on transient failures (connection errors + retry_status_codes). |
backoff_factor |
0.5 |
Exponential backoff factor between retries. |
retry_status_codes |
[429,500,502,503,504] |
JSON array of status codes to retry (entries are coerced to integers; a non-numeric entry raises a ConfigError). Retry-After is honored when present, regardless of status code. |
verify_ssl |
true |
Set false to skip TLS verification (not recommended). |
ca_bundle_path |
— | Path to a custom CA bundle; overrides verify_ssl when set. |
proxies |
{} |
JSON object, e.g. {"https": "http://proxy:8080"}. Only one proxy is used per client (prefers https, then http, then any key given) — httpx applies a single proxy to all requests rather than mounting per scheme. |
requests_per_second |
— | Client-side throttle applied per partition. |
Auth (auth_type)
auth_type |
Extra options |
|---|---|
none (default) |
— |
basic |
username, password |
bearer |
token |
api_key |
api_key_value, api_key_name (default X-API-Key), api_key_location (header | query, default header) |
header |
auth_headers — JSON object merged into request headers verbatim; the escape hatch for custom schemes |
oauth2_client_credentials |
oauth_token_url, oauth_client_id, oauth_client_secret, oauth_scope, oauth_audience, oauth_extra_params (JSON), oauth_client_auth_mode (basic | body, default basic), oauth_token_field (default access_token), oauth_expires_in_field (default expires_in) |
OAuth2 tokens are fetched lazily per executor process and cached until ~30 seconds before expiry, then refreshed automatically.
Response format
| Option | Default | Description |
|---|---|---|
response_format |
json |
How the response body is parsed: json, csv, or xml. |
csv_delimiter |
, |
CSV only — the field delimiter. |
xml_attribute_prefix |
_ |
XML only — prefix for keys derived from XML attributes (matching Spark's XML source convention). |
csv— a header row is required; each data row becomes one record, every value a string (coerce via the schema, or letinfer_schemasniff the types). Empty cells becomenull. Incompatible withrecords_path(the body already is the record list) and withpagination_type=cursor(no envelope to read a next-cursor from).xml— the element tree maps onto nested dicts/lists: repeated elements become arrays, attributes become{prefix}-prefixed keys, and an element with both attributes and text keeps its text under{prefix}VALUE. Dot paths (records_path,next_cursor_path,stream_cursor_field) and nested-struct schemas then work exactly as for JSON; paths are relative to inside the root element (e.g.items.itemfor<response><items><item>...).
Records extraction
| Option | Default | Description |
|---|---|---|
records_path |
— | Dot path to the record list inside the parsed body (JSON/XML), e.g. envelope.payload. If unset, tries the body itself (if it's a list) or common wrapper keys (results, data, items, records, value), else treats the whole body as one record. |
flatten |
false |
Flattens nested objects into dotted column names (e.g. address.city). Lists are left as-is. |
Schema
Schemas follow the structs pattern: nested objects are declared and
returned as real StructType/ArrayType columns — build them as
StructType objects, not DDL strings, and never model nested data as
JSON-string columns.
Manually defined (preferred for production jobs):
from pyspark.sql.types import ArrayType, IntegerType, StringType, StructField, StructType
schema = StructType(
[
StructField("id", IntegerType()),
StructField("address", StructType([StructField("city", StringType())])),
StructField("tags", ArrayType(StringType())),
]
)
df = spark.read.format("rest_api").schema(schema).option("url", ...).load()
| Option | Description |
|---|---|
.schema(StructType(...)) |
Preferred — pass the StructType directly on the DataFrameReader. |
schema |
Same, as an option: pass your_struct_type.json() (Spark's JSON schema representation, also what df.schema.json() produces). Parsed via StructType.fromJson, which — unlike StructType.fromDDL — is pure Python and needs no active SparkContext; that matters because DataSource.schema() runs in an isolated Python worker process with no driver context. A DDL string ("id int, name string", auto-detected by the missing leading {) is still accepted for back-compat. |
infer_schema |
true to sample the first page and infer a schema from the sampled values — see below. |
Inferred (Auto Loader-style, for all three response formats):
| Option | Default | Description |
|---|---|---|
infer_schema |
false |
Sample the first page and infer the schema from the sampled records. |
infer_sample_size |
100 |
Max records sampled for inference. |
infer_column_types |
true |
Infer real leaf types. Set false for Auto Loader's conservative default — every leaf a string — while still preserving the full nested struct/array shape (nested data is never collapsed to a JSON string, in either mode). For untyped formats (csv/xml, where every raw value is a string), true additionally sniffs string values into long/double/boolean/date/timestamp. |
schema_hints |
— | Auto Loader-style overrides applied on top of the inferred schema: comma-separated "<column path> <type>" entries, e.g. "version int, user_info.dob date, tags array<int>". Dotted paths descend through structs and array-of-struct elements; a hinted top-level column that wasn't inferred is appended. Only valid together with infer_schema=true (with an explicit schema, fold the type into the schema itself). |
Inference merges field sets across sampled records, widens mixed numeric
samples to double (and mixed date/timestamp to timestamp), and falls
back to string only on genuine type conflicts.
Nested response objects map onto nested StructType/ArrayType schema
fields automatically. ISO-8601 timestamp/date strings are parsed for
TimestampType/DateType columns.
Type coercion: values are best-effort coerced to match the declared
column type — a stringified number/boolean ("123", "true") coerces to
Integer/Boolean, and a non-string value in a StringType column is
stringified. This matters because REST APIs commonly stringify numbers to
dodge JS float-precision issues. A value that genuinely can't be coerced
(e.g. an object where a scalar is expected) raises SchemaTypeError naming
the exact field path and both types involved, instead of silently reaching
Spark's Arrow conversion and failing there with an opaque, unattributed
error several layers removed from the actual problem.
Schema drift
The schema is fixed once resolved — it is not re-inferred mid-batch or
mid-stream. By default, fields the API returns that aren't in the schema are
silently dropped (and fields the schema expects but the API omits become
null). Two options change that:
| Option | Default | Description |
|---|---|---|
rescued_data_column |
— | Name of a StringType column (must be declared in the schema) that captures any top-level record keys not otherwise mapped, as a JSON string. null when nothing drifted. With infer_schema=true, this column is added to the inferred schema automatically; with a DDL/JSON schema, you must declare it yourself or resolution fails with a clear error. |
fail_on_new_fields |
false |
Raise immediately the first time a record has a top-level key outside the schema, instead of silently dropping it. Takes precedence over rescued_data_column if both are set. |
Drift detection is top-level only — new keys appearing inside an array
element aren't (currently) surfaced by either option. One nuance: with
flatten=true, nested dict keys are promoted to top-level dotted names
before drift detection runs, so nested-dict drift actually is caught in
that combination — only drift nested inside an array stays invisible either
way.
In-band errors
Some APIs report failure inside a successful HTTP response — ArcGIS, for
example, answers 200 OK with {"error": {...}} in the body.
raise_for_status() never sees those; untreated, they surface as confusing
downstream symptoms ("zero records to infer from" at schema inference, or
pagination silently ending early and truncating the data). Two composable
mechanisms catch them; every page of every request is checked, including the
schema-inference sample:
| Option | Default | Description |
|---|---|---|
error_path |
— | Dot path into the parsed body (JSON/XML). Any response where the path resolves to a non-null value fails the read with a ResponseError carrying that value, e.g. .option("error_path", "error") for ArcGIS. |
For checks a dot path can't express, attach a response_validator
function to a RestApiDataSource subclass and register the subclass under
its own name. The validator receives the parsed body of each page and
returns an error message to fail the read (or None to accept it); raising
its own exception also fails the read. When both are configured,
error_path is checked first.
from spark_rest_api_reader import RestApiDataSource
class ArcGisDataSource(RestApiDataSource):
@classmethod
def name(cls):
return "arcgis_rest"
@staticmethod
def response_validator(body):
if isinstance(body, dict) and "error" in body:
return f"ArcGIS error: {body['error']}"
spark.dataSource.register(ArcGisDataSource)
df = spark.read.format("arcgis_rest").option("url", ...).load()
It must be a subclass attribute rather than an .option() because option
values are plain strings, and because data source code runs in isolated
worker processes: a subclass defined in a notebook is cloudpickled by
value at register() time, so the attached function travels to every
worker — module-level state (a registry, a global) would not.
Pagination (pagination_type) — within one partition
pagination_type |
Extra options |
|---|---|
none (default) |
One request per partition. |
page_number |
page_param (default page), start_page (default 1), page_size_param, page_size. Stops when a page comes back empty, checked via records_path the same way records are actually extracted (so a custom envelope's emptiness is read correctly, not just the top-level body/common wrapper keys) — also stops on a non-JSON response body (e.g. a 204 on the true last page) rather than continuing to increment. |
offset_limit |
offset_param (default offset), limit_param (default limit), limit (default 100), start_offset (default 0). Stops when a page returns fewer than limit records, again counted via records_path. |
cursor |
cursor_param (default cursor), next_cursor_path (required — dot path to the next-cursor value in the response body; JSON/XML only). Stops only when the path resolves to null or "" — a falsy-but-real value like 0 or false is a legitimate cursor and does not stop pagination. |
link_header |
Follows the standard Link: <url>; rel="next" response header. Stops when absent. |
max_pages_per_partition (default 10000) is a safety valve against
runaway pagination loops.
Partitioning — how the pull fans out across Spark
| Option | Description |
|---|---|
partitions |
JSON array of objects; one partition per object, each merged into that partition's query params. A _url key inside an object overrides the base URL entirely (e.g. per-resource-id endpoints). Each partition still runs the configured pagination_type to walk all of its own pages. Every entry must be a JSON object — a non-object entry raises a ConfigError naming the offending index. |
pages |
Integer — auto-generates one partition per page number (1..pages, using page_param), for maximum parallelism against APIs where pages can be fetched independently. Mutually exclusive with in-partition pagination (each partition fetches exactly its one page). |
| (neither set) | Single partition; pagination_type walks all pages sequentially. |
Streaming (spark.readStream)
| Option | Description |
|---|---|
stream_cursor_field |
Dot path to the high-water-mark field in each returned record (e.g. updated_at, or meta.updated_at for a nested field). |
stream_cursor_param |
Query param name sent upstream with the last-seen cursor value (e.g. updated_since). |
stream_initial_value |
Starting cursor value for the very first micro-batch (default ""). |
stream_cursor_is_numeric |
false (default). Set true if stream_cursor_field is a raw (non-zero-padded) number — see below. |
Each micro-batch pulls everything from stream_cursor_field > last value
using whatever pagination_type is configured, all within a single
partition. Simple and correct; if you need intra-batch parallelism, prefer
the batch reader with partitions on a schedule instead.
Cursor comparison: by default, "greater than" is a string comparison
— correct for ISO-8601 timestamps and zero-padded numeric IDs, but wrong for
raw (non-zero-padded) numeric cursors once they cross a digit-count boundary
("9" > "10" is True lexicographically). Set stream_cursor_is_numeric=true
for a numeric cursor field to compare correctly instead.
Restart/checkpoint safety requires pyspark >= 4.2. This reader implements
the admission-control latestOffset(start, limit) signature, which is the
only one that actually receives the restored checkpoint offset after a
query restart (driver restart, job retry, redeploy, etc.) — pyspark added
this in 4.2.0. On an older pyspark/DBR where only the legacy zero-arg
latestOffset() convention exists, the reader still works, but the cursor
can only be tracked in an in-memory attribute that resets on every fresh
process: a restart resumes from stream_initial_value again, reprocessing
everything already ingested, rather than crashing. If your streaming jobs
need to survive restarts without full reprocessing, confirm the cluster's
pyspark version is >= 4.2 before relying on this in production.
Examples
examples/batch_example.py— batch read into Delta, with commented alternatives for every pagination style (page_number,offset_limit,cursor,link_header), auto page-range/explicit-partition fan-out,records_path/flattenfor custom envelopes, schema drift handling (rescued_data_column/fail_on_new_fields), passing aStructTypethrough theschemaoption,infer_schemawithschema_hints, andcsv/xmlresponse formats.examples/auth_examples.py— everyauth_type(none,basic,bearer,api_keyas a header or query param,headerfor custom schemes,oauth2_client_credentials) as an independent, copy-pasteable snippet.examples/streaming_example.py— incremental streaming pull driven by a cursor field, with Spark handling checkpointing between micro-batches.
Development
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
Tests spin up a real local SparkSession plus a real local HTTP server (not
mocks) so that requests issued from PySpark's separate worker processes are
exercised end-to-end.
See CONTRIBUTING.md for the full development guide, and CHANGELOG.md for release history.
License
Metadata
Release files for spark-rest-api-reader 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| spark_rest_api_reader-0.3.0.tar.gz | 56.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| spark_rest_api_reader-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 96.5 kB
Release files / spark_rest_api_reader-0.3.0.tar.gz
| Download URL | spark_rest_api_reader-0.3.0.tar.gz |
|---|---|
| Size | 56.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d09015499ab6538b3e086d3cc312f961a156e859c9374b572b992102782cc61f
|
|
BLAKE2b-256 checksum How to use checksums |
25390f69ab9445adff8f003ab870e9062623a1f127b6553b390438344408ede6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 24, 2026.
Transparency logRelease files / spark_rest_api_reader-0.3.0-py3-none-any.whl
| Download URL | spark_rest_api_reader-0.3.0-py3-none-any.whl |
|---|---|
| Size | 39.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c8049c2909e5f3735a6a66b36264914f790caacf8a4337bf10c8ffd38a64fc25
|
|
BLAKE2b-256 checksum How to use checksums |
bf926c5cb87b5ab5e7cd2bf4d5785a1e6f7254928d30d38f4eaa0d1d1c23ffcd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 24, 2026.
Transparency log