A complete ADBC service in about 200 lines of Python, built with grainlift-python. Any ADBC application connects to it through the native Grainlift driver; the service itself needs no database, SQL engine or downstream driver.
Requires Python 3.13+, uv and Rust 1.97+ (to build the native Grainlift ADBC driver once).
git clone https://github.com/Query-farm/grainlift.git ../grainlift
(cd ../grainlift && cargo build --locked -p adbc-driver-grainlift)
uv sync --locked
Start the service (python -m grainlift_hello_world works too):
uv run grainlift-hello-world
No credentials are needed. The service is read-only, so it accepts anonymous clients (see Authentication).
Haybarn, Query.Farm's DuckDB
distribution, loads the Grainlift driver through the adbc_scanner extension.
In a second terminal, run examples/query.sql:
export GRAINLIFT_DRIVER=$PWD/../grainlift/target/debug/libadbc_driver_grainlift.dylib # .so on Linux
uvx haybarn-cli < examples/query.sql
The same script runs unchanged in the DuckDB CLI. It prints:
┌───────────────┐
│ message │
│ varchar │
├───────────────┤
│ Hello, world! │
└───────────────┘
┌─────────┬────────────┐
│ numbers │ total │
│ int64 │ int128 │
├─────────┼────────────┤
│ 100000 │ 4999950000 │
└─────────┴────────────┘
...
adbc_scan sends its quoted SQL to this service. The rows come back as an
ordinary relation that you can join, aggregate or export locally.
examples/python_client.py uses the standard ADBC
driver manager:
uv run examples/python_client.py
| Module | Contents |
|---|---|
grainlift_hello_world.worker |
The service: HelloWorker → HelloConnection → HelloStatement, plus the two result styles below |
grainlift_hello_world.__main__ |
The grainlift-hello-world command |
The service answers three queries:
| Query | Result | Demonstrates |
|---|---|---|
SELECT 'Hello, world!' AS message |
one row | the smallest possible result |
SELECT * FROM numbers(n) |
0..n-1 | a generator of Arrow batches |
SELECT * FROM running_total(n) |
0..n-1 with a running sum | a serializable ResultProducer |
n ranges from 0 to 100000. Anything else is an ADBC INVALID_ARGUMENT error
with SQLSTATE 42000. The example matches these queries exactly rather than
pretending to parse SQL.
HelloStatement implements the ADBC statement lifecycle: set the SQL, then
prepare, execute_schema and execute. Preparation matters because clients
such as adbc_scanner prepare every query before running it.
Both styles stream lazily in batches of at most 1024 rows, and you can mix them freely within one service.
- Generator (
numbers): returnQueryResult(schema, iterator). It's the simplest option, and it can hold resources such as an open database cursor. The iterator lives in server memory until the client finishes or releases the result. - Producer (
running_total): subclassResultProduceras a dataclass whose fields are the entire resumable state, implementproduce(), and returnQueryResult.from_producer(schema, state). Over HTTP the state is serialized into the encrypted continuation token after each batch. The server keeps no iterator or replay batch between fetches, and a retried fetch recomputes its batch from the token. This is the same approach VGI-RPC streams use.
Pick a producer when the state is small and serializable, such as offsets, keyset cursors or counters. Pick a generator when it isn't.
Anonymous access is opt-in in grainlift-python. This example enables it because
it only serves public, read-only data: its command calls
grainlift.cli.run(..., auth="anonymous"). Requests without credentials act as
the shared anonymous principal.
- Set
GRAINLIFT_TOKENon both sides to connect as an authenticated principal instead. A client that sends a wrong token is rejected, never downgraded to anonymous. - Run
uv run grainlift-hello-world --auth tokento require a token. The server prints a generated token whenGRAINLIFT_TOKENis unset.
For a service that can write data or expose private data, keep the default
token authentication. In your own hosting code, anonymous access is
Service.app(anonymous_principal="anonymous"), optionally alongside tokens=.
uv run grainlift-hello-world --help lists them. The same command-line host is
available for any worker as grainlift serve module:Factory.
--host waitress(default): loopback HTTP for development.--host granian: supervised loopback HTTP that drains on SIGTERM/SIGINT.--host mtls: verified TCP/mTLS; client certificates identify callers.--port: listening port (default 8080). Point the client at a different port withGRAINLIFT_ENDPOINT.
For mTLS, supply the server chain, key, client CA and authorized client URI SAN:
uv run grainlift-hello-world --host mtls --port 8443 \
--tls-cert server.pem --tls-key server-key.pem \
--client-ca clients-ca.pem --client-uri spiffe://example.org/client
export GRAINLIFT_ENDPOINT=tls+tcp://127.0.0.1:8443
export GRAINLIFT_TLS_CA=server-ca.pem GRAINLIFT_TLS_CERT=client.pem GRAINLIFT_TLS_KEY=client-key.pem
export GRAINLIFT_TLS_SERVER_NAME=localhost # the DNS name in the server certificate
uv run examples/python_client.py
These hosts are for development and bind to loopback. For production deployment, limits and the security contract, see the SDK's HOSTING.md.
uv run --no-sync ruff check src tests examples && uv run --no-sync ruff format --check src tests examples
uv run --no-sync mypy src tests examples
uvx pydoclint --config pyproject.toml src/ tests/ examples/
GRAINLIFT_DRIVER=../grainlift/target/debug/libadbc_driver_grainlift.dylib uv run --no-sync pytest
Native integration tests skip when GRAINLIFT_DRIVER is unset. They include
running examples/query.sql in the Haybarn CLI (a dev dependency), which
downloads the adbc_scanner extension on first use. CI builds a
pinned native-driver revision and runs everything on Linux and macOS with
Python 3.13 and 3.14. See VALIDATION.md for a recorded local run.