Hardcharger™ dlt Destination
Load anything dlt can extract into Hardcharger: remote DuckDB 2.0, over Chargewire, as Arrow.
dlt-hardcharger is the dlt (data load tool) destination for
Hardcharger™, GizmoData's DuckDB 2.0 server. Install it and
destination="hardcharger" works in any dlt pipeline: every write disposition, schema evolution,
nested tables, state kept at the destination, and pipeline.dataset() to read the data back.
Data files are bulk-loaded as Arrow through the
Hardcharger ADBC driver over Chargewire HTTP(S):
a million rows land in a fraction of a second, where INSERT statements take minutes
(measured below).
Install
pip install dlt-hardcharger
Python 3.10 to 3.14 (dlt's range). Installs dlt[sqlalchemy,parquet]>=1.32.0 and
sqlalchemy-hardcharger>=1.1.0, which
brings the driver.
Quick start
import dlt
import dlt_hardcharger
pipeline = dlt.pipeline(
pipeline_name="quick_start",
destination=dlt_hardcharger.hardcharger(
credentials="hardcharger://hardcharger:change-me@localhost:31337?tls=false"
),
dataset_name="weather",
)
readings = [
{"city": "Lima", "temp_c": 19.0, "hours": [{"hour": 6, "temp_c": 17.5}]},
{"city": "Pune", "temp_c": 31.5, "hours": [{"hour": 6, "temp_c": 24.0}]},
]
info = pipeline.run(readings, table_name="readings")
print(info.has_failed_jobs)
dataset = pipeline.dataset()
print(dataset.readings.select("city", "temp_c").order_by("city").fetchall())
print(dataset.readings__hours.select("hour", "temp_c").order_by("temp_c").fetchall())
False
[('Lima', 19.0), ('Pune', 31.5)]
[(6, 17.5), (6, 24.0)]
The dataset is a schema on the server (weather), each resource a table in it, and each nested
list a child table (readings__hours). TLS is on unless the URL says ?tls=false (a local
development server). The port defaults to 31337, and to 443 for a
GizmoData Cloud host.
Credentials
The URL is the one
sqlalchemy-hardcharger takes:
hardcharger://user:password@host[:port][/catalog][?option=value&...]
Give it to the destination in code, as above, or let dlt find it in its
configuration: then the pipeline names the
destination and nothing else, and the package needs no import. dlt finds the destination through
the package's dlt entry point.
In .dlt/secrets.toml:
[destination.hardcharger]
credentials = "hardcharger://hardcharger:change-me@localhost:31337?tls=false"
import dlt
pipeline = dlt.pipeline(pipeline_name="from_secrets", destination="hardcharger", dataset_name="crm")
pipeline.run([{"id": 1, "name": "Ada"}], table_name="contacts")
print(pipeline.dataset().contacts.select("id", "name").fetchall())
[(1, 'Ada')]
The URL can also be given in parts (query holds the URL's options):
[destination.hardcharger.credentials]
host = "localhost"
port = 31337
username = "hardcharger"
password = "change-me"
database = "memory" # the catalog; leave it out for the server's default
[destination.hardcharger.credentials.query]
tls = "false"
import dlt
pipeline = dlt.pipeline(
pipeline_name="from_parts", destination="hardcharger", dataset_name="crm_parts"
)
pipeline.run([{"id": 2, "name": "Grace"}], table_name="contacts")
print(pipeline.dataset().contacts.select("id", "name").fetchall())
[(2, 'Grace')]
Or in the environment:
export DESTINATION__HARDCHARGER__CREDENTIALS="hardcharger://hardcharger:change-me@localhost:31337?tls=false"
import dlt
pipeline = dlt.pipeline(pipeline_name="from_env", destination="hardcharger", dataset_name="crm_env")
pipeline.run([{"id": 3, "name": "Edsger"}], table_name="contacts")
print(pipeline.dataset().contacts.select("id", "name").fetchall())
[(3, 'Edsger')]
dlt_hardcharger.hardcharger(...) also takes a SQLAlchemy Engine made from such a URL,
engine_kwargs for sqlalchemy.create_engine, and destination_name="warehouse" to read
destination.warehouse.credentials instead. Token and browser (SSO) sign-in, client certificates
and the other URL options are the dialect's: see
its README.
Write dispositions
All three, with every strategy dlt has for them:
| Disposition | Strategies | On Hardcharger |
|---|---|---|
append |
Arrow bulk ingest into the table | |
replace |
truncate-and-insert (default) |
Empties the table, then loads into it |
insert-from-staging |
Loads into the staging dataset, then swaps the rows in one transaction | |
staging-optimized |
The same as insert-from-staging (DuckDB has no faster table swap) |
|
merge |
delete-insert (default) |
By primary_key and/or merge_key, with hard_delete and dedup_sort |
upsert |
One MERGE INTO by primary_key, with hard_delete |
|
insert-only |
MERGE INTO that only inserts keys not yet there |
|
cdc |
An upsert that also deletes what the load no longer has | |
scd2 |
Slowly changing dimension: versions with _dlt_valid_from / _dlt_valid_to |
import dlt
import dlt_hardcharger
pipeline = dlt.pipeline(
pipeline_name="merge_example",
destination=dlt_hardcharger.hardcharger(
credentials="hardcharger://hardcharger:change-me@localhost:31337?tls=false"
),
dataset_name="shop",
)
@dlt.resource(primary_key="id", write_disposition={"disposition": "merge", "strategy": "upsert"})
def orders(rows):
yield rows
pipeline.run(orders([{"id": 1, "status": "new"}, {"id": 2, "status": "new"}]))
pipeline.run(orders([{"id": 2, "status": "shipped"}, {"id": 3, "status": "new"}]))
print(pipeline.dataset().orders.select("id", "status").order_by("id").fetchall())
[(1, 'new'), (2, 'shipped'), (3, 'new')]
Merges and the staged replaces load into a staging dataset first (shop_staging, a second
schema in the same catalog) and move the rows in one transaction.
create_primary_keys=True and create_unique_indexes=True make dlt create PRIMARY KEY and
UNIQUE constraints from its hints. They are off by default, as on dlt's other destinations: a
constraint slows loading and dlt's merges don't need one.
Speed
A Parquet file is appended to its table with the driver's Arrow bulk ingest
(cursor.adbc_ingest): Arrow batches sent over several connections at once, inside the load
job's transaction, so a file is in the table whole or not at all. Parquet is this destination's
default file format. Asked for loader_file_format="typed-jsonl", it sends INSERT statements,
as dlt's SQLAlchemy destination does for every database.
One million rows of eight columns (integers, a float, two strings, a timestamp, a boolean), from a laptop (Apple M-series) into a Hardcharger 2.12.0 server on the same machine, dlt 1.32.0:
| Source | File format | Load step | Whole run |
|---|---|---|---|
| Arrow table | Parquet → Arrow ingest | 0.14–0.20 s | 0.3 s |
| Python dicts | Parquet → Arrow ingest | 0.19 s | 12.9 s |
| Python dicts | typed-jsonl → INSERT statements |
143 s | 158 s |
The load step is the part this package decides. With Python dicts, dlt's own extract and normalize steps take the other twelve seconds; hand dlt Arrow tables or pandas frames where a source has them and they pass through untouched.
import dlt
import dlt_hardcharger
import pyarrow as pa
pipeline = dlt.pipeline(
pipeline_name="arrow_example",
destination=dlt_hardcharger.hardcharger(
credentials="hardcharger://hardcharger:change-me@localhost:31337?tls=false"
),
dataset_name="metrics",
)
rows = 500_000
table = pa.table({"id": pa.array(range(rows)), "squared": pa.array(i * i for i in range(rows))})
pipeline.run(table, table_name="squares")
print(pipeline.dataset().squares.select("squared").max().fetchscalar())
249999000001
Catalogs
The catalog is the URL's path and the dataset a schema in it: hardcharger://host/lake with
dataset_name="raw" loads into lake.raw. Without a path, the pipeline works in the server's
default catalog. dlt's own tables and the staging dataset are in the same catalog.
import dlt
import dlt_hardcharger
from sqlalchemy import create_engine, text
# For the example, a second catalog on the server: an attached DuckLake or database file in real use.
engine = create_engine("hardcharger://hardcharger:change-me@localhost:31337?tls=false")
with engine.connect() as conn:
conn.execute(text("ATTACH IF NOT EXISTS ':memory:' AS lake"))
conn.commit()
pipeline = dlt.pipeline(
pipeline_name="catalog_example",
destination=dlt_hardcharger.hardcharger(
credentials="hardcharger://hardcharger:change-me@localhost:31337/lake?tls=false"
),
dataset_name="raw",
)
pipeline.run([{"id": 1}, {"id": 2}], table_name="events")
with engine.connect() as conn:
print(conn.execute(text("SELECT count(*) FROM lake.raw.events")).scalar())
2
Data types
| dlt type | Hardcharger type |
|---|---|
text |
VARCHAR |
double |
DOUBLE |
bool |
BOOLEAN |
bigint |
BIGINT; with precision 8, 16, 32, 64 or 128 bits: TINYINT, SMALLINT, INTEGER, BIGINT, HUGEINT |
decimal |
DECIMAL(38,9), or the column's precision and scale |
wei |
DECIMAL(38,0) |
timestamp |
TIMESTAMP WITH TIME ZONE (microseconds): an instant. A value without an offset is read as UTC |
timestamp with timezone=False |
TIMESTAMP; with precision 0, 3 or 9: TIMESTAMP_S, TIMESTAMP_MS, TIMESTAMP_NS. A value with an offset is stored as its UTC clock reading |
date |
DATE |
time |
TIME, whatever the timezone hint: a value's clock reading is stored and its UTC offset dropped (13:45-05:00 is 13:45) |
binary |
BLOB |
json |
JSON |
Each is tested both ways (Arrow ingest and INSERT statements): the value read back is the value
sent, and both ways store the same value.
Nothing stored depends on a time zone, the client's or the server's. A Hardcharger session works in the server's time zone, and DuckDB gives a value that arrives without an offset that zone's offset wherever a zoned type is expected. The destination never lets that happen:
- A
timestampis always sent as an instant, so it is the same instant on every server. Read it back and you get that instant; as text (::VARCHAR), DuckDB prints it in the session's zone. - A
timeis neverTIME WITH TIME ZONE. Parquet and Arrow have no time-of-day type with an offset, so the offset of a value is gone before a file reaches the server, and the server would attach its own: the same pipeline would store13:45-05on one server and13:45+00on another. dlt'stimezonehint therefore has no effect on atimecolumn, as on dlt's own DuckDB destination. If the offset matters, store an instant (timestamp) or keep the offset in a column of its own. - Fractional seconds beyond a column's
precisionare cut, never rounded, by both load paths:10:20:30.987654in aprecision=0column is10:20:30.
The test suite loads every kind of timestamp, time and date from clients and into servers in UTC, New York and Kolkata (a half-hour offset), in the same zone and in different ones, over both load paths, and requires the same stored values every time.
Reading the data
pipeline.dataset() gives dlt's relations (.fetchall(), .arrow(), .df()), and takes
DuckDB SQL: dlt parses it with sqlglot's DuckDB dialect. Table names in it need no schema.
import dlt
import dlt_hardcharger
pipeline = dlt.pipeline(
pipeline_name="reading_example",
destination=dlt_hardcharger.hardcharger(
credentials="hardcharger://hardcharger:change-me@localhost:31337?tls=false"
),
dataset_name="trips",
)
pipeline.run(
[
{"city": "Lima", "km": 4.5},
{"city": "Lima", "km": 12.0},
{"city": "Pune", "km": 7.25},
],
table_name="rides",
)
dataset = pipeline.dataset()
print(
dataset(
"SELECT city, sum(km) AS km, count(*) AS rides FROM rides GROUP BY ALL ORDER BY city"
).fetchall()
)
print(dataset.rides.select("city", "km").order_by("km").arrow().to_pydict())
with pipeline.sql_client() as client:
rides = client.make_qualified_table_name("rides")
print(client.execute_sql(f"SELECT max(km) FROM {rides} WHERE city = %s", "Lima"))
[('Lima', 16.5, 2), ('Pune', 7.25, 1)]
{'city': ['Lima', 'Pune', 'Lima'], 'km': [4.5, 7.25, 12.0]}
[(12.0,)]
With pipeline.sql_client(), qualify table names (client.make_qualified_table_name("rides")):
the client doesn't change the session's schema.
With dlt's own SQLAlchemy destination
Installing this package also teaches dlt's built-in sqlalchemy destination about Hardcharger,
for a pipeline that is already written against it. The URL then needs its catalog:
import dlt
pipeline = dlt.pipeline(
pipeline_name="plain_sqlalchemy",
destination=dlt.destinations.sqlalchemy(
credentials="hardcharger://hardcharger:change-me@localhost:31337/memory?tls=false"
),
dataset_name="plain",
)
pipeline.run([{"id": 1}], table_name="numbers")
print(pipeline.dataset().numbers.select("id").fetchall())
[(1,)]
It gets the same type mapping, identifier rules and DuckDB SQL for reads. It does not get the
Arrow bulk ingest (rows go as INSERT statements) or the MERGE INTO strategies (upsert,
insert-only, cdc): those are the hardcharger destination's.
Good to know
- Names are case-insensitive. DuckDB keeps an identifier's case and compares without it. dlt's
default naming makes everything
snake_case, so nothing collides. With a case-sensitive naming convention (sql_cs_v1) the case is kept on the server, and a table with bothNameandnameis refused by dlt before anything is created ("Destination is case insensitive"). - dlt's files are loaded one transaction each, twenty at a time by default (
load.workers), each on its own connection: the destination lets SQLAlchemy's pool open as many as dlt asks for (max_overflow=-1; passengine_kwargsto choose otherwise). A failed file leaves nothing behind. A value the table can't hold (a number out of range, a broken constraint) fails its job at once; a dropped connection is retried. - Unsigned Arrow integers take the signed type of the same width (dlt's mapping): a
uint32column is anINTEGER, and a value above 2,147,483,647 in it is refused. Cast such a column to the next wider type before loading. scd2andcreate_primary_keys=Truedon't go together when the key is the natural key: scd2 keeps several versions of a key, which aPRIMARY KEYon it forbids.- One transaction writes to one catalog in DuckDB. A pipeline works in one catalog (tables, dlt's own tables and the staging dataset), so loads are not affected.
- dlt 1.32.0 or later, below 2. The destination builds on dlt's SQLAlchemy destination; each release is tested against the newest dlt and against 1.32.0.
Development and testing
make install # a virtualenv in .venv with the package and its test tools
make lint # ruff and mypy
make test # unit and integration tests
make readme # every example in this file, run as written
The integration tests and the README examples run against a real Hardcharger server: the
hardcharger package downloads the server binary and
starts it on a free port. A test fails if the server can't start; none is skipped.
License
Apache License 2.0. See LICENSE and NOTICE.
Hardcharger™ and GizmoData™ are trademarks of GizmoData LLC. dlt is a project of dltHub Inc.; this package is not affiliated with or endorsed by dltHub.
Metadata
Release files for dlt-hardcharger 1.0.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 | Provenance | Actions |
|---|---|---|---|---|
| dlt_hardcharger-1.0.0.tar.gz | 191.4 kB | With provenance | Details for dlt_hardcharger-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| dlt_hardcharger-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for dlt_hardcharger-1.0.0-py3-none-any.whl |
Total release size: 215.1 kB
Release files / dlt_hardcharger-1.0.0.tar.gz
| Download URL | dlt_hardcharger-1.0.0.tar.gz |
|---|---|
| Size | 191.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
323ec61a7ac71cb85709682ff22acf7a98b10472648584d9c2a7da3e572e9816
|
|
BLAKE2b-256 checksum How to use checksums |
614eaeb2f84d91e558c80daa6d37b05784a529d70f3e82af0a32390fb112550d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 Nov 13, 2026.
Transparency logRelease files / dlt_hardcharger-1.0.0-py3-none-any.whl
| Download URL | dlt_hardcharger-1.0.0-py3-none-any.whl |
|---|---|
| Size | 23.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bde26b9924f4873ffdf464c9b8bd911b1c95b56e182b2e3e9e5ea51bec8f232c
|
|
BLAKE2b-256 checksum How to use checksums |
86d9dd6fc4891d15adca73252aca056c8096d3b6396564cc669d9ae504caddea
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 Nov 13, 2026.
Transparency logSecurity for dlt-hardcharger 1.0.0
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.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for dlt_hardcharger-1.0.0.tar.gz
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 Nov 13, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.