Extract and load your largest data streams with a framework you can trust for billions of records.
Bizon is a lightweight Python ETL framework built around a checkpointed producer → queue → consumer pipeline. It extracts data from API clients, databases and event streams, optionally transforms records in flight with Python, and loads them into your warehouse — with native fault-tolerance, resumable checkpoints, and very high throughput, while staying small enough to read end to end.
- Features
- Architecture
- Installation
- Quickstart
- CLI Reference
- Configuration Reference
- Connectors
- Sync Modes
- Engine Configuration
- Transforms
- Secrets & References
- Monitoring & Alerting
- Multi-Stream Routing
- Documentation & Contributing
- License
- Natively fault-tolerant — a checkpointing mechanism tracks progress so an
incrementalorstreampipeline resumes from its last committed cursor after a crash or restart. Afull_refreshpipeline deliberately restarts instead, since it republishes the whole table. - High throughput — designed to process billions of records, using Polars DataFrames for memory-efficient, vectorized buffering and Parquet for batch loads.
- Queue-system agnostic — run on an in-process Python queue, RabbitMQ, or Kafka/Redpanda
behind a single
Queueinterface; adapters can be written for any broker. - Pluggable connectors — 10 built-in sources and 5 destinations, all behind clean
AbstractSource/AbstractDestinationinterfaces. Sources are auto-discovered — no registration needed. - Multiple sync modes —
full_refresh,incremental(append-only), and continuousstream. - Secret resolution — keep secrets out of YAML with
gsm://(Google Secret Manager) andenv://references, resolved before validation so connectors only ever see plain strings. - In-pipeline transforms — apply user-defined Python to records in flight.
- Pipeline metrics — exhaustive metrics with Datadog & OpenTelemetry tracing: ETAs, records processed, completion %, and source ↔ destination latency.
- Lightweight & lean — a minimal codebase with few core dependencies (
requests,pyyaml,pydantic,sqlalchemy,polars,pyarrow).
Bizon uses a producer–consumer pattern with pluggable components:
YAML Config → RunnerFactory → Producer → Queue → Consumer → Destination
↑ ↓
Source Backend (checkpoints)
- The Producer pulls records from the Source in iterations and pushes them onto the Queue as Polars DataFrames.
- The Consumer pulls from the queue, applies Transforms, and writes batches to the Destination.
- The Backend persists cursors so syncs are resumable.
Checkpointing & recovery. The producer writes its source cursor to the backend every
syncCursorInDBEvery iterations, and the consumer records a destination cursor after each
successful write. On restart, an incremental or stream pipeline reads the last destination
cursor and resumes from the next iteration. Bizon's delivery contract is at-least-once — on
recovery a batch may be re-written, so destinations are designed to tolerate duplicate writes.
A full_refresh run is not resumed. It republishes the whole table, so a partially-fetched run has
nothing worth continuing: an interrupted job is retired and the next run starts a fresh one, staging
into a clean temp table. (Resuming one would leave the published table stuck at its previous contents
indefinitely, since the run never reaches the final iteration that swaps the table in.)
| Abstraction | Base Class | Location |
|---|---|---|
| Source | AbstractSource |
bizon/source/source.py |
| Destination | AbstractDestination |
bizon/destination/destination.py |
| Queue | AbstractQueue |
bizon/engine/queue/queue.py |
| Backend | AbstractBackend |
bizon/engine/backend/backend.py |
| Runner | AbstractRunner |
bizon/engine/runner/runner.py |
Requires Python ≥ 3.10, < 3.13.
Note: package metadata currently declares
>=3.9, but bizon does not import on 3.9 — parts of the codebase useX | Noneannotations that Python 3.9 evaluates at import time. Use 3.10 or newer.
pip install bizonOptional features are installed via extras:
| Extra | pip install |
Enables |
|---|---|---|
postgres |
bizon[postgres] |
PostgreSQL backend |
bigquery |
bizon[bigquery] |
BigQuery backend & destinations (incl. Storage Write API) |
kafka |
bizon[kafka] |
Kafka/Redpanda queue and Kafka source (Avro/Schema Registry) |
rabbitmq |
bizon[rabbitmq] |
RabbitMQ queue |
gsheets |
bizon[gsheets] |
Google Sheets source |
datadog |
bizon[datadog] |
Datadog metrics & tracing |
secretmanager |
bizon[secretmanager] |
gsm:// Google Secret Manager references |
Combine extras as needed, e.g. pip install 'bizon[bigquery,kafka,secretmanager]'.
# Install uv (if not already installed)
pip install uv
# Clone and install with all extras and dependency groups
git clone https://github.com/bizon-data/bizon-core.git
cd bizon-core
uv sync --all-extras --all-groups
# Run tests
uv run pytest tests/
# Format & lint (Ruff, line length 120)
uv run ruff format .
uv run ruff check --fix .This example needs zero external dependencies — it uses the in-process Python queue and a
SQLite backend (both defaults), reading from the built-in dummy source and writing to logger.
Create config.yml:
name: demo-creatures-pipeline
source:
name: dummy
stream: creatures
authentication:
type: api_key
params:
token: dummy_key
destination:
name: logger
config:
dummy: dummyRun it:
bizon run config.ymlThe CLI entry point is bizon (bizon.cli.main:cli).
| Command | Description |
|---|---|
bizon run <config.yml> |
Run a pipeline from a YAML config |
bizon source list |
List available sources and their streams |
bizon stream list <source> |
List a source's streams, flagged [Supports incremental] / [Full refresh only] |
bizon stream reset <config.yml> |
Queue a stream reset for the next run of that pipeline |
bizon secrets check <config.yml> |
Dry-run every gsm:// / env:// reference and report (masked) results |
bizon destination |
Subcommand group (no subcommands yet) |
bizon run config.yml \
--custom-source ./my_source.py \ # Custom Python file implementing a Bizon source
--runner thread \ # thread | process | stream (default: thread)
--log-level INFO \ # DEBUG | INFO | WARNING | ERROR | CRITICAL
--env-file .env # Load env vars from a .env file (auto-detected if omitted)The --runner flag overrides engine.runner.type in the config; --log-level overrides
engine.runner.log_level.
bizon secrets check config.yml [--env-file .env]Resolves every reference in the config (without running the pipeline) and prints each one with a masked ✓/✗ status. Exits non-zero if any reference fails — handy as a pre-deploy gate.
A pipeline is defined by a single YAML file validated against BizonConfig
(bizon/common/models.py). Unknown top-level keys are rejected.
| Key | Required | Description |
|---|---|---|
name |
✅ | Unique name identifying this sync |
source |
✅ | Source connector config (see below) |
destination |
✅ | Destination connector config; routed by its name field |
transforms |
— | List of in-pipeline transforms (default []) |
engine |
— | Backend, queue and runner config (sensible defaults if omitted) |
secrets |
— | Provider defaults for gsm:// / env:// resolution |
monitoring |
— | Datadog metrics & tracing |
alerting |
— | Slack alerting |
streams |
— | Multi-table routing (requires source.sync_mode: stream) |
Common SourceConfig fields (bizon/source/config.py); each connector adds its own:
| Key | Default | Description |
|---|---|---|
name |
— | Connector name (e.g. hubspot, notion, kafka) |
stream |
— | Stream to sync |
sync_mode |
full_refresh |
full_refresh | incremental | stream |
cursor_field |
None |
Timestamp field for incremental filtering (e.g. updated_at) |
authentication |
None |
Auth block (type + params); connector-specific |
force_ignore_checkpoint |
false |
Ignore existing checkpoints and restart from iteration 0. Redundant for full_refresh, which always starts fresh |
reset |
false |
Re-fetch the whole stream and replace the destination table, then resume incremental (details) |
max_iterations |
None |
Cap iterations per run (default: run until source is exhausted) |
api_config.retry_limit |
10 |
Retries before giving up on an API call |
source_file_path |
None |
Path to a custom source file (same as --custom-source) |
name: hubspot contacts to bigquery
source:
name: hubspot
stream: contacts
properties:
strategy: all # connector-specific: fetch all properties
authentication:
type: api_key
api_key: ${env://HUBSPOT_API_KEY} # inline secret reference
destination:
name: bigquery
config:
project_id: my-gcp-project-id
dataset_id: bizon_test
dataset_location: US
create_dataset: false
gcs_buffer_bucket: bizon-buffer
gcs_buffer_format: parquet
buffer_size: 10 # in MB
buffer_flush_timeout: 300 # in seconds
engine:
backend:
type: bigquery
config:
database: my-gcp-project
schema_name: bizon_backend
syncCursorInDBEvery: 10Full, copy-pasteable examples live next to each connector under
bizon/connectors/**/config/*.example.yml.
All sources are auto-discovered. Run bizon source list to see what's installed and
bizon stream list <source> for a source's streams.
| Source | Connects to | Example streams | Auth | Incremental | Extra |
|---|---|---|---|---|---|
hubspot |
HubSpot CRM (v3) | contacts, companies, deals |
api_key, oauth | — | — |
notion |
Notion API | pages, databases, data_sources, blocks, blocks_markdown, users (+ all_*) |
api_key | ✅ | — |
kafka |
Kafka / Redpanda | topic (Avro + Schema Registry) |
basic (SASL) | stream | kafka |
gsheets |
Google Sheets | worksheet |
service account, ADC | — | gsheets |
cycle |
Cycle (GraphQL) | customers |
api_key | — | — |
periscope |
Periscope / Sisense | charts, dashboards, views, users, databases |
cookies | — | — |
sana_ai |
Sana AI Insight Reports | insight_report |
oauth | — | — |
pokeapi |
PokéAPI (public) | pokemon, berry, item |
none | — | — |
gbif |
GBIF biodiversity (public) | occurrence |
none | — | — |
dummy |
Mock source (testing/demos) | creatures, plants |
api_key, oauth | — | — |
notion is the reference incremental source — see
bizon/connectors/sources/notion/src/source.py for get_records_after().
| Destination | Writes to | Sync modes | Notable features |
|---|---|---|---|
bigquery |
BigQuery | full / incremental / stream | Batch loads via GCS + Parquet; atomic table swaps via free copy jobs; async/batched load jobs; partitioning; optional schema unnesting |
bigquery_streaming |
BigQuery | full / incremental / stream | Legacy streaming insert API; dynamic schema evolution; large-row fallback to load jobs |
bigquery_streaming_v2 |
BigQuery | full / incremental / stream | Storage Write API (protobuf); higher throughput; multi-threaded appends; schema caching |
file |
Local NDJSON file | full / incremental / stream | Atomic finalize on full refresh; append on incremental; optional unnesting |
logger |
stdout (loguru) | full / incremental / stream | Logs records — for testing & debugging |
Adding connectors. Sources are auto-discovered — drop a connector under
bizon/connectors/sources/{name}/src/ and it appears. Destinations register in three places
(see the guide). Use the /new-source and /new-destination Claude skills, or read
docs/contributing/adding-sources.md and
docs/contributing/adding-destinations.md.
| Mode | Behavior |
|---|---|
full_refresh |
Re-syncs all data from scratch on each run (default) |
incremental |
Syncs only new/updated records since the last successful run (append-only) |
stream |
Continuous streaming for real-time data (e.g. Kafka) |
Incremental sync fetches only new or updated records since the last successful run, using an append-only strategy.
source:
name: your_source
stream: your_stream
sync_mode: incremental
cursor_field: updated_at # The timestamp field to filter records by┌─────────────────────────────────────────────────────────────────────┐
│ INCREMENTAL SYNC FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Producer checks for last successful job │
│ └─> Backend.get_last_successful_stream_job() │
│ │
│ 2. If found, creates SourceIncrementalState: │
│ └─> last_run = previous_job.created_at │
│ └─> cursor_field = config.cursor_field (e.g., "updated_at") │
│ │
│ 3. Calls source.get_records_after(source_state, pagination) │
│ └─> Source filters: WHERE cursor_field > last_run │
│ │
│ 4. Records written to temp table: {table}_incremental │
│ │
│ 5. finalize() appends temp table to main table │
│ └─> BigQuery: free copy job (WRITE_APPEND), creating the │
│ main table on first run; other destinations: INSERT INTO │
│ └─> Deletes temp table │
│ │
│ FIRST RUN: No previous job → falls back to get() (full refresh) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Sources must implement get_records_after() to support incremental sync:
| Source | Cursor Field | Notes |
|---|---|---|
notion |
last_edited_time |
Supports pages, databases, blocks, blocks_markdown streams |
| (others) | Varies | Check source docs or implement get_records_after() |
Destinations must implement finalize() with incremental logic:
| Destination | Support | Notes |
|---|---|---|
bigquery |
✅ | Append-only via temp table |
bigquery_streaming_v2 |
✅ | Append-only via temp table |
file |
✅ | Appends to existing file |
logger |
✅ | Logs completion |
name: notion pages incremental sync
source:
name: notion
stream: pages # Options: databases, data_sources, pages, blocks, users
sync_mode: incremental
cursor_field: last_edited_time
authentication:
type: api_key
params:
token: secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
database_ids:
- "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
page_size: 100
destination:
name: bigquery
config:
project_id: my-gcp-project
dataset_id: notion_data
dataset_location: US
gcs_buffer_bucket: my-gcs-bucket
gcs_buffer_format: parquet
engine:
backend:
type: bigquery
config:
database: my-gcp-project
schema_name: bizon_backend
syncCursorInDBEvery: 2On the first incremental run (no previous successful job):
- Falls back to the
get()method (full-refresh behavior) - All data is fetched and loaded
- The job is marked successful
- Subsequent runs use
get_records_after()with thelast_runtimestamp
A reset re-fetches the whole stream once and replaces the destination table, then resumes incremental from that run. Use it when the table has drifted — a backfill, a bug in a transform, or records the source changed without bumping its cursor field.
There are three ways to ask for one; all do the same thing:
# One-shot, for a run you launch yourself
bizon run config.yml --reset
# Queued in the backend, for a pipeline whose command line is fixed by a scheduler.
# The next `bizon run config.yml` picks it up — no change to the cron/Airflow job.
bizon stream reset config.yml
bizon stream reset config.yml --cancel # changed your mind
# One config templated across streams? Pick which stream to reset.
bizon stream reset config.yml --stream dealssource:
sync_mode: incremental
reset: true # same effect, set in the configWhat the run does differently:
- Producer ignores the watermark and calls
get()instead ofget_records_after() - Destination materializes the run as a full refresh, replacing the table rather than appending to
it (for
bigquery: staging into{table}_temp, then aWRITE_TRUNCATEcopy job) - The job row stays
incremental, so the next run uses this run as its new watermark
Notes:
- Scoped to a single stream. The request is keyed on
(name, source_name, stream_name)— the same triple as the watermark it overrides — so resetting one stream never affects another, even under the same pipeline name.bizon stream resettakes the stream from the config;--streamoverrides it. - Only meaningful with
sync_mode: incremental; ignored (with a warning) for the other modes. - Supported by every destination with a working full-refresh path, since that is what a reset
materializes as. The exception is
bigquery_streaming, which has no staging table and appends even on a full refresh; a reset there is rejected at config validation rather than duplicating your data. - A reset that crashes is retried as a reset — the request stays bound to its job, so a rerun cannot silently degrade into an append.
bizon stream resetwrites to the backend, so it must reach the same one the pipeline uses. With the default file-basedsqlitebackend that means the same machine and file.
The engine block configures three pluggable subsystems. All have defaults, so engine is
optional for local runs.
The backend stores Bizon's state (jobs and cursors). Configured under engine.backend.
| Type | Use case |
|---|---|
sqlite |
File-based SQLite — local development (default) |
sqlite_in_memory |
Memory-only SQLite — unit tests |
postgres |
PostgreSQL — production, frequent cursor updates (bizon[postgres]) |
bigquery |
BigQuery — lightweight production state storage (bizon[bigquery]) |
syncCursorInDBEvery (default 2) controls how often the source cursor is flushed to the
backend — lower values mean finer-grained recovery, higher values mean less write overhead.
engine:
backend:
type: postgres
config:
host: localhost
port: 5432
database: bizon
schema_name: bizon
username: ${env://PG_USER}
password: ${env://PG_PASSWORD}
syncCursorInDBEvery: 10The queue carries records from the producer to the consumer. Configured under engine.queue.
| Type | Use case |
|---|---|
python_queue |
In-process — local development & testing (default) |
rabbitmq |
RabbitMQ — production, high throughput (bizon[rabbitmq]) |
kafka |
Kafka / Redpanda — production, high throughput, strong persistence (bizon[kafka]) |
Spin up a broker locally with the provided compose files:
docker compose --file ./scripts/queues/kafka-compose.yml up # Kafka
docker compose --file ./scripts/queues/redpanda-compose.yml up # Redpanda
docker compose --file ./scripts/queues/rabbitmq-compose.yml up # RabbitMQ# Kafka
engine:
queue:
type: kafka
config:
queue:
bootstrap_server: localhost:9092 # Kafka:9092 / Redpanda:19092
# RabbitMQ
engine:
queue:
type: rabbitmq
config:
queue:
host: localhost
queue_name: bizonThe runner controls how the producer and consumer execute. Configured under engine.runner.
| Type | Description | Best for |
|---|---|---|
thread |
ThreadPoolExecutor (default) |
I/O-bound sources (APIs, DBs) |
process |
ProcessPoolExecutor, true parallelism |
CPU-bound sources/transforms |
stream |
Single-threaded, inline | Real-time stream sync & multi-stream routing |
Transforms apply user-defined Python to each record as it flows through the pipeline. Each
transform receives the record's parsed payload as a data dict, mutates it, and the result is
re-serialized. Transforms are applied sequentially, in order.
transforms:
- label: debezium
python: |
from datetime import datetime
cluster = data['value']['source']['name'].replace('_', '-')
partition = data['partition']
offset = data['offset']
kafka_timestamp = datetime.utcfromtimestamp(
data['value']['source']['ts_ms'] / 1000
).strftime('%Y-%m-%d %H:%M:%S.%f')
deleted = False
if data['value']['op'] == 'd':
data = data['value']['before']
deleted = TrueA TransformModel has just two fields: label (a display name) and python (the code). There
are no built-in transforms — the logic is entirely yours.
Keep secrets out of YAML by referencing them with a URI scheme. Resolution runs once over the raw
config before validation (bizon/engine/resolvers/), so connectors need no changes — they
always read plain strings.
gsm://<id>→ Google Secret Manager, latest version (ADC auth). Pin withgsm://<id>/versions/<N>, or pass a fullgsm://projects/<p>/secrets/<id>/versions/<N>path. Requiresbizon[secretmanager].env://<VAR>→ environment variable.- Inline form — embed in a larger string with
${...}, e.g.dsn: "postgres://u:${gsm://db-pw}@host/db"(multiple tokens allowed). - An optional
secrets:block holds provider defaults (e.g.secrets.gsm.project_id).
Validate every reference before running:
bizon secrets check config.ymlAdd a provider by dropping one adapter in bizon/engine/resolvers/adapters/ and one entry in
_SCHEME_FACTORIES (bizon/engine/resolvers/resolver.py).
Monitoring (bizon/monitoring/) — emit pipeline metrics and traces to Datadog. Install
bizon[datadog].
monitoring:
type: datadog
config:
enable_tracing: true
datadog_agent_host: localhost # or datadog_host_env_var: DD_AGENT_HOST
datadog_agent_port: 8125
tags:
env: production
team: dataAlerting (bizon/alerting/) — post alerts to Slack on configured log levels.
alerting:
type: slack
log_levels: [ERROR] # defaults to [ERROR]
config:
webhook_url: ${env://SLACK_WEBHOOK_URL}For one-to-many pipelines (common with Kafka CDC), the streams block maps multiple source
streams to their own destination tables and schemas in a single run. It requires
source.sync_mode: stream and the stream runner.
source:
name: kafka
stream: topic
sync_mode: stream
# topics are auto-extracted from the streams block below
bootstrap_servers: your-kafka-broker:9092
group_id: your-consumer-group
destination:
name: bigquery_streaming_v2
config:
project_id: your-gcp-project
dataset_id: your_dataset
dataset_location: US
unnest: true
streams:
- name: users
source:
topic: cdc.public.users
destination:
table_id: your-gcp-project.your_dataset.users
clustering_keys: [id]
record_schema:
- { name: id, type: INTEGER, mode: REQUIRED }
- { name: email, type: STRING, mode: NULLABLE }
- name: orders
source:
topic: cdc.public.orders
destination:
table_id: your-gcp-project.your_dataset.orders
clustering_keys: [id, user_id]
engine:
runner:
type: streamSee bizon/connectors/sources/kafka/config/kafka_streams.example.yml for a complete example.
- Adding sources —
docs/contributing/adding-sources.md - Adding destinations —
docs/contributing/adding-destinations.md - AI-assisted connector generation —
docs/ai-connector-guide.md,docs/ai-destination-guide.md - Annotated reference connector —
docs/reference-connector.md - Contribution guidelines —
CONTRIBUTING.md - Release notes —
CHANGELOG.md
Claude Code skills are available for common workflows: /new-source, /new-destination, and
/run-checks.
Bizon is released under the GNU General Public License v3.0. See LICENSE.