dbextractors
Table extraction from MySQL, MSSQL, PostgreSQL and Firebird into PostgreSQL, driven by a plain configuration dict.
It is not a general-purpose ETL framework. It does one direction of one job,
and it does it for tables large enough that the naive version stops working:
COPY … FROM STDIN as the only write path, a vectorised conversion layer,
incremental and hash-diff loading, keyset pagination, optional SSH tunnelling,
and a full load that swaps through a shadow table instead of dropping the
target.
from dbextractors import run
df = run(config, dialect="mysql")
It grew out of a set of hand-copied extractor blocks that had accumulated for years across several deployments, serving roughly 670 tables. That history shows in two places, and both are deliberate:
- The configuration contract is frozen. Keys are never renamed and never change meaning; new capability arrives as a new key with a safe default.
- Older configuration shapes still work. See Backward compatibility — you do not need any of it for a new pipeline.
Runtime. This package targets the Mage 0.9.79 image: Python 3.10, pandas 1.5, SQLAlchemy 1.4, with dependencies pinned to the exact versions that image ships. Installation on 3.11+ is refused on purpose rather than failing later in production. Widening that is a known and separate piece of work.
Why this exists
- So that a deployment can add an extractor without copying one. A new table
should be a few lines of configuration, and the complicated part should live in
one place that is versioned and tested. That is why
run()is the entire API and why the configuration contract is frozen: 15 hand-copied blocks became one call, and a single loader block now serves 101 pipelines. - So that awkward sources are reachable at all. Firebird is the case in point.
Its Python driver,
fdb, is the oldest and most fragile of the four, which is why it sits behind its own extra (pip install "dbextractors[firebird]") and gets its own CI job markedcontinue-on-error— a Firebird image that will not start is worth seeing, but should not block a release. General-purpose tooling tends not to carry a source that needs that much special handling. - So that the things a real deployment needs are in the box. Reaching a source
that is not directly routable is the clearest example, and it is often left out of
open-source ETL tooling. Here it is
connection_mode: direct | ssh | auto, host key verification throughssh_host_key_checking, and a tunnel started as an OpenSSH subprocess underPR_SET_PDEATHSIG— rather than a Python SSH library — specifically so a failed run cannot leave an orphaned process holding the forwarded port open.
Mage integration
The package does not require Mage — there is a single lazy import of mage_ai,
used only to locate io_config.yaml when running inside it. Under Mage the
pipeline stays two blocks: a config block builds the dict and a
data_loader block hands it to run(). One such loader covers every database
extraction in a repository. The block itself, what has to be passed through it and
what it returns are in The Mage loader block.
Running without Mage
Nothing in the extraction path needs Mage. The one mage_ai import is lazy and
returns None if the package is absent, so run() works from a plain script, a
cron job or a test:
pip install "dbextractors[target,mysql]" # extras name the sources you read
export DBX_TARGET_DSN="postgresql://user:pass@localhost:5432/warehouse"
import logging
from dbextractors import run
config = {
'TABLE': {'source_name': 'orders', 'output_schema': 'raw_shop',
'output_name': 'orders'},
'SOURCE_DB': {'user': 'readonly', 'password': '…', 'host': '10.0.0.1',
'database': 'shop'},
'LOAD_SETTINGS': {'load_method': 'hash', 'primary_column': 'id'},
'connection_mode': 'direct',
}
status = run(config, dialect='mysql', logger=logging.getLogger('dbx'))
logger is optional and takes any standard logging.Logger; the docstring calls it
the Mage logger because that is where it usually comes from, not because it has to
be one.
The return value is a status DataFrame, one row per source database, with the
columns table, source, rows_written, load_method, success,
is_incremental, data_present, fallback_reason, connection_mode and error.
success is what a caller should assert on; fallback_reason tells you why a run
took the expensive path, and load_method what it actually did, which is not always
what you asked for — see Load methods.
Where writing goes is resolved in this order, and the order is not arbitrary —
it was set by a production failure in which the target was taken from POSTGRES_*
in a deployment where POSTGRES_PORT is the port published on the host, not the
one the database listens on inside the network:
DBX_TARGET_DSN— explicit, overrides everything. Use this outside Mage.- The
io_config.yamlprofile (warehouseby default;TARGET_PROFILEin the configuration, orDBX_TARGET_PROFILEfor a whole container). This is what the replaced extractors used, so dbextractors lands where they landed. POSTGRES_HOST/POSTGRES_DB/POSTGRES_USER, withPOSTGRES_PORTdefaulting to 5432 and an empty password allowed — for runs outside Mage where there is noio_config.yaml.DBX_GOLDEN_DSN, so deployments that set it because of that bug keep working.
If none of them resolve, the run fails with TargetConnectionError naming
everything it tried.
One thing stays Mage-shaped even standalone: target column names reproduce Mage's
reserved-word prefixing, so a source column called name arrives as _name. That
is the compatibility contract rather than a leftover, and it applies to a brand-new
table too — see Columns added to the target.
Configuration
A plain dict. Only what the table actually needs has to be filled in —
everything outside TABLE and SOURCE_DB has a sensible default.
config = {
# Which source to read from: firebird | mysql | mssql | postgres.
# Under Mage this is read by the loader block and passed to run(), because
# Mage does not hand a block its own configuration from metadata.yaml.
'DIALECT': 'firebird',
# ── what moves, and where to ───────────────────────────────────────────
'TABLE': {
'source_name': 'source_table', # table in the source
'source_schema': '', # MSSQL/PostgreSQL; leave empty for MySQL and Firebird
'output_schema': 'raw_source', # target schema; created if absent
'output_name': 'target_table', # target table (alias: 'output_table')
'only_selected_columns': False, # True = take only 'selected_columns'
'selected_columns': [],
'excluded_columns': ['password'], # control columns (PK, hash, modification date) cannot
# be excluded — they are put back
'where_clause': None, # appended to the source query's WHERE
'empty_rows_ok': True, # source is empty -> the target may be overwritten empty.
# Without it the target is left alone, which is safer.
},
# ── where it is read from ──────────────────────────────────────────────
'SOURCE_DB': {
'user': 'readonly_user',
'password': os.environ['SOURCE_DB_PASSWORD'], # a secret store, not the source file
'database': 'source_database',
'charset': 'utf8mb4',
'host': '10.0.0.1', # direct connection
'port': 3306, # empty = the dialect's default port
'ssh_address_or_host': 'ssh.example.com', # tunnelled; used according to 'connection_mode'
'ssh_username': 'tunnel',
'ssh_pkey': '/home/src/.ssh/id_rsa',
'ssh_port': 22,
'ssh_wait_timeout': 20,
'ssh_host_key_checking': 'off', # off | accept-new | strict
# 'off' is inherited behaviour: the host fingerprint
# is not verified at all. Use 'accept-new' for a new
# deployment. See "SSH host key verification".
'remote_bind_address': ('127.0.0.1', 3306),
'local_bind_address': ('127.0.0.1', 0), # 0 = let the system pick the port
},
# ── how it is loaded ───────────────────────────────────────────────────
'LOAD_SETTINGS': {
'load_method': 'hash', # full | full_by_source | incremental
# hash (= hash_diff) | id_watermark
# parent_incremental is inferred from 'incremental_parent_*'
# An unknown value raises — no quiet fallback to full load.
'primary_column': 'id', # PK, case-sensitive (alias: 'primary_key_column')
'batch_size': 500000, # empty = derived from a size estimate
'hash_column': None, # row-hash column, when the source already has one
# time-based increment (load_method: incremental)
'created_at_column': 'created_at',
'updated_at_column': 'updated_at',
'days_back': 7, # window back, default 14
# window taken from a parent table (parent_incremental)
'incremental_parent_table': None, # setting it selects the strategy
'incremental_parent_date_column': None,
'incremental_parent_key_column': None, # the child's FK; None = 'parent_id'
'incremental_parent_id_column': None, # the parent's key; None = 'id'
# hash_diff
'hash_include_columns': None, # None = every column
'hash_exclude_columns': [],
'ignore_hash': False,
# other
'compute_row_hash': None, # None = the strategy decides
'surrogate_key_enabled': False, # a surrogate key instead of the source PK
'surrogate_key_definition': None, # the SQL expression that produces it
'partition_by_source': False, # multi-source: slices by '_source'
# (shorthand for partition_by below)
'partition_by': None, # target partitioning; None = none. See "Target partitioning".
# {'column': 'day', 'mode': 'range_month'}
# mode: list | range_day | range_month | range_year
'skip_size_estimate': False,
'strict_integer_precision': False, # fail the run instead of writing an integer that
# float64 already rounded. Off = the inherited
# behaviour. See "Large integers and NULL".
'convert_nchar_to_varchar': False, # MSSQL only: read NVARCHAR/NCHAR/NTEXT through a
# server-side CONVERT so the text arrives whole.
# Off = the inherited truncation. Turning it on
# means reloading that table — see "MSSQL and
# NVARCHAR text".
},
# ── top-level keys ─────────────────────────────────────────────────────
'connection_mode': 'direct', # direct | ssh | auto ('auto' is the inherited default)
'DATABASES': None, # databases for a multi-source run (several of the same
# shape on one server); key absent = the single database
# from SOURCE_DB, empty list = the control layer selected
# none, which is a successful run
'FINGERPRINT': {}, # cheap source fingerprint: {'mode': 'datsave'|'aggregate',
# 'timestamp_column': …, 'aggregate_columns': [...]}
# Skips databases in which nothing changed.
'TARGET_PROFILE': None, # io_config.yaml profile, None = 'warehouse'
'DEBUG': False, # verbose logging (type maps, WHERE, columns)
}
Older configurations put some of these keys at the top level instead of in a section, and those are still read as a fallback. An unknown key is logged, not rejected. See Backward compatibility.
A TARGET_DB section, if present, is ignored: where the data is written is
decided by io_config.yaml (profile warehouse), optionally overridden by
TARGET_PROFILE or the DBX_TARGET_DSN environment variable.
Load methods
load_method in LOAD_SETTINGS decides how much of the source is read and what
happens to the target. It is the single most consequential line in a table's
configuration: the difference between the cheapest and the most expensive method on
the same table is hours, and the difference between one that tracks deletions and
one that does not is a _deleted_in_source column that silently never changes.
An unknown value raises. The predecessor quietly fell back to a full load, which is the most expensive possible outcome of a typo in YAML.
| method | for | needs from the source | what it does to the target | cost per run |
|---|---|---|---|---|
full |
small tables, or no usable key | nothing; a primary_column if you want dedup and an index |
rebuilds it — shadow table, then TRUNCATE + INSERT … SELECT in one transaction |
the whole source, every time |
full_by_source |
one table fed from several databases of the same shape | multi_source / DATABASES; MSSQL only today |
replaces that source's _source slice, leaves the other slices alone |
the whole source per database, minus the ones the fingerprint skipped |
incremental |
a trustworthy modification-time column | updated_at_column, optionally created_at_column |
upserts the window | only the window |
hash_diff (hash) |
no CDC log, but a stable PK | primary_column; source-side hashing, so not Firebird |
upserts changed rows and marks keys that stopped arriving | the whole source — a hash for every row — then fetches only what changed |
id_watermark |
append-only, increasing PK | an increasing primary_column |
appends rows above MAX(pk) in the target |
only the rows above the watermark |
parent_incremental |
child tables with no change date of their own | a parent table with a date column, and a key into it; Firebird only today | deletes the window and reloads it, in one transaction | only the window |
Two methods are not selected by name:
parent_incrementalis inferred.load_methodstaysincremental; the strategy is recognised only by the presence ofincremental_parent_table, exactly as the predecessor does. Without that rule those child tables would go toincremental, look for a change timestamp of their own, fail to find one and raise. It affects 11 of one deployment's 102 configurations.full+resume_full_loadbecomesid_watermark. Underneath, resuming is literally the watermark strategy: read rows whose key is aboveMAX(pk)in the target, no state stored anywhere. Before this was wired up, the config layer read the key and the whole table was rebuilt anyway — on one production table the difference between fetching 823 thousand rows and reloading 9.9 million.force_reloadwins over it, because forcing a reload means trusting nothing the target remembers, and a watermark is exactly what must not be trusted then.
full
Reads the whole source and rebuilds the target. Roughly 90 tables run this way. It is the only method that needs nothing from the source: no key, no timestamp, no hash. Reach for it when the table is small enough that reading all of it is cheaper than working out what changed, when there is no column you would trust to tell you what changed, and as the first run of any of the incremental methods — they all fall back to it.
The rebuild is a load into a shadow table followed by TRUNCATE and
INSERT … SELECT in a single transaction. It is deliberately not a DROP and
not a RENAME. DROP … CASCADE takes down up to 102 dependent views, and an
interruption leaves the target truncated — documented by a run that ended with 11.4
of 18.8 M rows. A RENAME is worse in a quieter way: a view holds an OID rather
than a name, so after renaming it stays bound to the old table and serves last run's
data without complaint. With the swap, a dependent view keeps working throughout and
a failure part-way through leaves the target exactly as it was.
Do not reach for it on a large table on a schedule. The whole source crosses the
wire every time, and on the widest tables that is hours. resume_full_load is not
an answer to that — it turns the run into id_watermark and inherits that method's
blind spots along with its speed.
Two things about an empty source are worth knowing before you meet them. If the
source returns no rows and empty_rows_ok is not set, the target is left alone;
with empty_rows_ok it is overwritten empty, which is what that key is for. And a
failed size estimate fails the run loudly rather than reporting zero rows —
zero rows plus empty_rows_ok would wipe a target and finish green.
full_by_source
Several source databases of the same shape pour into one target table, partitioned
by _source so that one of them can be rewritten without touching the others. 16
tables run this way, and only MSSQL offers it today. Reach for it when you have that
exact shape: n identical databases, one warehouse table, and a need to reload one of
them on its own.
Rows carry _source regardless of how many databases a given run selected. That
looks like a detail and is a trap: deletion is by _source, so if the column
appeared only when the run happened to be processing several databases, a run over a
single database would wipe the whole table. multi_source in the configuration
decides it, not the length of the list.
FINGERPRINT makes it cheaper. One aggregate query against the source — COUNT
catches deletes, MAX(pk) catches inserts, a timestamp or a column sum catches
edits — and a database whose fingerprint has not moved is skipped without fetching a
row. It is written only after a successful transfer, and it is trusted only when
the target still holds that slice. That second condition is not extra caution: the
first database of a rebuilt table recreates it, so from the second one onwards the
table exists again and the fingerprint alone would skip every remaining database. In
the predecessor that left 3 rows of 220,972 in the target.
The one thing to know going in: row_hash is not computed here. The column
exists in the target and stays NULL. That is deliberate — the predecessor reads
these tables in one streamed pass with no hashing, because on the largest of them
hashing means HASHBYTES over 156 columns and 1.4 M rows every night on a
memory-starved SQL Server, and no dbt model touches row_hash on those tables
anyway. Set compute_row_hash: true if yours does.
incremental
A window over a modification-time column: roughly, WHERE COALESCE(updated_at, created_at) >= cutoff, where the cutoff is days_back days ago rounded down to
midnight (default 14). About 27 tables run this way. Reach for it when the source
maintains a modification timestamp you actually trust, and when you can live without
knowing about deletions.
The window is measured from the time of the run, not from the state of the target, and three consequences follow that are all easy to overlook:
- An outage longer than
days_backpunches a permanent hole in the data. If the pipeline does not run for 10 days anddays_backis 7, rows changed in the first three days of the outage fall outside the window and are never picked up — the next run again looks back only 7 days. Nothing reveals it: the run is green and moves plenty of rows. The only repair is a full load. Widen the window with theincremental_lookback_hoursruntime variable before the first run after a long outage, not after. - A row with no change timestamp is never transferred.
NULLin both columns fails the condition. Falling back ontocreated_atsoftens that; it does not remove it. - Deletions are invisible.
_deleted_in_sourceis declared on the target but staysFALSEunder this method, exactly as it did in the predecessor. Marking deletions here would mean re-reading every source key on every incremental run, which is the cost the method exists to avoid. A table whose deletions matter belongs onhash_diff.
A missing target and an empty target both fall back to a full load, and the fallback
is always logged and reported in fallback_reason. The target must already carry a
UNIQUE or PRIMARY KEY index over primary_column — without one there is nothing
for the upsert to rest on, and the run fails saying so rather than appending
duplicates.
hash_diff (alias hash)
The dominant method: about 530 of the ~670 tables. A row_hash is computed for
every source row — by the source itself where it can (SHA2 on MySQL, HASHBYTES
on MSSQL) — compared against the hash stored in the target, and only rows whose hash
moved are fetched and written. Reach for it when there is no CDC log and no
timestamp you trust, but there is a stable primary key. It is the only method that
genuinely maintains _deleted_in_source: the diff already holds the full set of
live keys, so marking what stopped arriving costs nothing extra, and a key that
comes back is unmarked again.
Also the most expensive: it is O(source rows, not changed rows) — measured at
~15,000 rows/s — because every row has to be hashed to find out whether it changed.
On a table where a timestamp would do the same job, incremental is cheaper by the
ratio of changed rows to total rows. Firebird does not offer it at all;
parent_incremental is that dialect's answer to the same problem.
The diff itself is a single JOIN inside PostgreSQL: the source hashes are COPYed
into a temporary table and joined against the target. It is not a dictionary of the
target snapshot in RAM and not a seen_keys set — at 11 M rows that was 2–3 GB, on
~530 tables, with three pipelines running concurrently.
The rule everything else follows from is that seeding row_hash must use the same
computation path as the diff. The source-side hash and the pandas hash do not
agree — measured against a real source, 4 of 51 tables matched, precisely the ones
with no binary columns — so a single fall onto the other path marks the whole table
as changed. This is handled for you, but it explains the fallbacks: the method falls
back to a full load when the target is missing, when it is empty, when row_hash is
entirely NULL (nothing to compare against), when the scan fails, when
ignore_hash is set, and when enough of the stored hashes disagree with freshly
computed ones that the column is treated as drifted and reseeded.
That last one is also the method's blind spot. The reseed only triggers on wholesale drift — essentially every row mismatched and none matched. Partial drift is invisible: the run stays green and simply transfers every row it thinks changed, which looks like a busy night rather than a fault. Nothing distinguishes "the hashes drifted" from "every row really did change", and both take the same full load.
hash_include_columns and hash_exclude_columns narrow what goes into the hash —
useful when a column changes on every read and would make every row look modified.
hash_download_batch_size sets how many changed keys go into one IN (…) when
fetching them. hash_diff_buffer_size is an inherited key whose original meaning
(when to flush the changed-key buffer to a file in /tmp) no longer exists, because
that list is now the result of a SQL query; it has been adopted onto the nearest
thing with the same meaning — how many source rows are read before being COPYed
into the snapshot — and still governs the peak memory of the diff phase.
id_watermark
Reads only rows whose primary key is above MAX(pk) in the target, by keyset
paging, which every dialect supports. Four tables run this way, all against
PostgreSQL sources, which is what the predecessor offered it for. The watermark is
not stored anywhere — there is no state file and no state table, so there is nothing
to lose and nothing to drift.
Reach for it when the source is genuinely append-only with a sequence-generated key,
and when the saving matters: it is the cheapest method here by a wide margin,
because it never looks at a row it has already seen.
That is also exactly what it cannot do. Two blind spots are properties of the method, not omissions, and both are pinned by tests:
- A row inserted below the watermark after the fact is never picked up. With a sequence that cannot happen; with manually assigned keys it can, and nothing in the run will say so.
- A deleted row is never marked.
_deleted_in_sourcestays permanentlyFALSE. Taking a live-key snapshot would mean reading the entire source table, and the whole saving would go with it.
An update below the watermark is likewise not transferred. If any of that matters
for your table, use hash or incremental instead. A missing target and an empty
target both fall back to a full load.
parent_incremental
For child tables — document lines, order lines — that carry no trustworthy change
date of their own but do have a key into a parent that does. The window is computed
over the parent (incremental_parent_table plus incremental_parent_date_column,
with an optional incremental_parent_date_column_fallback), using the same cutoff
incremental computes, so a child's window matches the one its parent's own run
used. 24 tables run this way, all Firebird. You do not select it by name: set
load_method: incremental and give it incremental_parent_table.
The join between the two tables is parent_id in the child and id in the parent —
what the predecessor hard-codes. Where they are named otherwise, name them:
incremental_parent_key_column and incremental_parent_id_column. Leaving them
unset keeps those two defaults, so no existing pipeline moves.
The mechanics differ from every other method. Child rows whose parent falls inside
the window are deleted from the target and loaded again. That is the only way to
notice a child row that vanished from the source — it has no change date, so a
deletion cannot be learned about any other way. Deletion here is physical: the row
leaves the target rather than getting _deleted_in_source = TRUE. Outside the
window nothing is deleted, even when the source no longer has the row. The DELETE
and the write are in one transaction, loaded through a staging table first; the
predecessor committed the DELETE and only then began streaming, so a stream that
fell over — over Firebird via SSH, not a theoretical possibility — left the target
without the deleted rows and the run merely looking unsuccessful.
Two things will surprise you, and both are deliberate parity with the predecessor:
- An empty target is not backfilled.
incrementalandid_watermarkfall back to a full load when the target exists but holds no rows. This one does not: it checks that the child and parent tables exist and nothing further, so over an empty target it transfers the window alone, leaves everything older missing, and the run comes out green with a partial table. Closing that gap would transfer a different set of rows than the old side does, so it is pinned by a test and can only change on purpose. The reasoning is recorded in Backward compatibility. days_backis ignored on this path. A deep cutoff applies instead. The predecessor has nodays_backbranch here, but the config layer substitutesdays_back: 14even where the configuration does not set it — and if that substituted default won, the child would get a "today minus 14 days" window instead. Documented in production: 0 rows transferred against 394 on the old side, withstatus = completed.
A missing target, and a parent that is not in the target, both fall back to a full load.
What every method does the same
Fallback to a full load is reported, never silent. Whenever one of the four
incremental methods cannot do its cheap thing — no target, no rows to compare
against, hashes drifted — it does the expensive thing and says so, in the log and in
the fallback_reason column of the returned frame. full_by_source is the
exception: it never falls back, because a full load of a multi-source table would
mean one database wiping the slices of all the others.
Writes go through COPY … FROM STDIN and nothing else, on every method.
All three managed columns exist on every method, but only some are maintained:
_timestamp |
row_hash |
_deleted_in_source |
|
|---|---|---|---|
full |
yes | yes | set FALSE |
full_by_source |
yes | NULL unless compute_row_hash: true |
set FALSE |
incremental |
yes | yes | never flipped — stays FALSE |
hash_diff |
yes | yes | maintained, both directions |
id_watermark |
yes | yes | never flipped — stays FALSE |
parent_incremental |
yes | yes | not used — deletion is physical, inside the window |
The distinction matters because a column that exists but never changes does not fail
a dbt model; it just makes it wrong. 133 dbt models read _deleted_in_source.
A column that appears in the source is handled differently by full loads and by
the rest. Seen in production on 14 Aug 2026, as
column "promo_code" ... does not exist. A full load adopts the new column — the
table is rewritten anyway, so widening costs nothing, and the column goes at the end
so existing column order is untouched. The four methods that touch existing data —
incremental, hash_diff, id_watermark and parent_incremental — drop it
instead, because they must not change the target's shape mid-flight, but they drop it
with a warning naming the specific column. The predecessor dropped it silently, which
is how a hole in the warehouse stays hidden. Either way the next full load makes it
up.
SSH host key verification
When the source is reached through a tunnel, the connection is opened with
StrictHostKeyChecking=no and UserKnownHostsFile=/dev/null — the far end is
not verified. This is inherited behaviour and defensible inside a closed
network, but it is the one place in the package where a connection can be
substituted.
SOURCE_DB.ssh_host_key_checking makes it a choice:
| value | behaviour |
|---|---|
off |
default — the fingerprint is not checked. Inherited behaviour. |
accept-new |
remember the fingerprint on first connect, refuse it if it changes |
strict |
the host must already be in known_hosts |
The default stays off deliberately: changing it would break runs against hosts
whose fingerprint was never recorded anywhere. For a new deployment
accept-new is the right choice — it protects against substitution and needs
nothing prepared in advance.
Both non-off values use the known_hosts of the user the process runs as.
Target partitioning
The target table can be partitioned with partition_by. Without it there is
no partitioning, which is the state of every existing table and does not
change.
'LOAD_SETTINGS': {
'partition_by': {
'column': 'day', # column in the target (after renaming, see below)
'mode': 'range_month', # list | range_day | range_month | range_year
'default_partition': True, # catch-all partition, on by default
},
}
The shorthand 'partition_by': 'country' means
{'column': 'country', 'mode': 'list'}. The older 'partition_by_source': True
keeps working and is exactly {'column': '_source', 'mode': 'list'}.
| mode | for | one partition per |
|---|---|---|
range_month |
fact tables with a document date, event streams | month |
range_day |
daily snapshots; note that 3 years is over a thousand partitions | day |
range_year |
long history where months would make too many partitions | year |
list |
a column with an enumerable set of values — _source, country, brand |
value |
Partitions are created automatically from the values actually present in the
data: a new month or a new value needs no manual DDL. The catch-all DEFAULT
partition takes NULL partition keys and anything unclassifiable — without it a
single such row would fail the whole run with "no partition found for row".
Three things worth knowing in advance:
- Name the column the way it is named in the target. If the name is a reserved
word it carries an underscore in the target (
date→_date) — see Columns added to the target. - An existing table is never rebuilt in place.
relkindcannot be changed in PostgreSQL, so converting a table meansDROP+CREATE, and targets have views hanging off them. Partitioning is therefore applied only to a table that does not exist yet; on an existing plain table it is only logged and the run finishes without partitioning. Converting is a manual migration — the procedure is in Enabling partitioning on an existing table. A mismatch in the partition key, by contrast, fails the run instead of warning. - Upsert against a partitioned target behaves differently inside, not outside.
PostgreSQL requires a unique index over a partitioned table to contain the
partition key, so
ON CONFLICT (id)has nothing to rest on there. The incremental strategies therefore write throughDELETEby key +INSERTin one transaction. A row whose partition value changes in the source is thereby moved, not duplicated — which is what a widened conflict key(id, day)would do.
Large integers and NULL
An integer column that also holds a NULL cannot be int64, so pandas infers
float64 — inside pd.read_sql, before any code of this package runs — and
float64 carries 53 bits of mantissa. Every integer above 2**53 is rounded on the
way in. Measured end to end on all four dialects, read back out of the target:
| source value | value in the target |
|---|---|
9007199254740993 |
9007199254740992 |
1234567890123456789 |
1234567890123456768 |
The trigger is the NULL, not the magnitude: the same values in a column without a
NULL arrive intact. Above 2**63 the run fails loudly instead.
This is inherited and is not fixed. Every predecessor extractor reads through a
bare pd.read_sql with no dtype= and loses the same bits in the same place, so
correcting the read would change what lands in the target relative to the
implementation this replaced. It reaches further than large numbers, too: because
the hash is rendered straight out of the frame, a plain nullable INTEGER holding
10 is hashed today as '1||10.0'. Changing the read dtype would recompute
row_hash for every table with a nullable numeric column and force a full reload
of all of them. The mechanism is pinned in tests/coerce/test_int64_precision.py.
What you can do is refuse to write a number nobody can trust:
LOAD_SETTINGS:
strict_integer_precision: true
The run then fails, naming the column and an offending value, whenever a float64
column bound for an integer column in the target holds a value whose absolute value
exceeds 2**53. Off by default, because on by default it would turn an unknown
number of currently-green runs red.
The boundary is inclusive: exactly 253 passes, because every integer up to and
including it survives float64 unchanged. The check is conservative in the other
direction — 2**53 + 2 is representable exactly and is still reported, since after
the read the rounded and the intact value are the same float64. And 2**53 + 1
rounds down to exactly 253, so the smallest possible corruption is the one case
no check placed here can see.
MSSQL and NVARCHAR text
MSSQL is read over a cp1250 connection, inherited character for character from
both predecessor extractors, and pymssql and FreeTDS disagree about what that
means: the bytes are produced as latin-1 and decoded as cp1250. An NVARCHAR,
NCHAR or NTEXT value is therefore cut at the first character latin-1 cannot
express — 'příliš žluťoučký kůň' arrives as 'p' — and a character latin-1 can
express comes back as a different one (ø as ř). ASCII is unaffected, and so are
the single-byte VARCHAR, CHAR and TEXT, which are read correctly because
of that charset and are the majority of a CP1250-collated source.
No charset value gets both right; seven were measured. What does is converting on the server instead:
LOAD_SETTINGS:
convert_nchar_to_varchar: true
The N-typed columns — and only those, chosen by their introspected type — are then
wrapped in CONVERT(VARCHAR(MAX), …) in the generated SELECT, so the server
converts them through the column's own collation and they arrive whole over the same
connection. What CP1250 cannot hold is degraded rather than truncating the value:
Cyrillic and emoji become ?, and ø is folded onto o.
Off by default, and per table. Switching it on changes what those columns hold,
and row_hash does not move with it — the digests were always computed by the
server from the raw NVARCHAR, so under load_method: hash existing rows are not
seen as changed. Run the table once with forced_full_load to repair its history.
The measurements, and the one case it is not for (an N-typed primary key), are in
Backward compatibility.
Columns added to the target
Three columns are added to the target and the dbt layer depends on them:
_deleted_in_source, _timestamp and row_hash. Which of them a run actually
maintains depends on the load method — see Load methods. The names
of the other columns do not change — including the underscore prefixes (_type,
_name, _date) that mage_ai.io.postgres produces today.
Running the tests
Most of the suite needs nothing but a checkout:
make install
make test # ~880 tests, no database required
The rest needs databases. One command brings up the target and all four sources, seeded:
cp .env.example .env
make db-up
make test # now ~1100 tests, nothing skipped
make db-down
Anything without a DSN skips rather than fails, so working on one dialect does not require running the other three. CI requires them to actually run — a test that quietly skips is a test that stopped protecting anything.
The seeds in docker/seed/ are not sample data. Each one is built around the
types that behave neither like numbers nor like text, because that is the bug
class string assertions cannot reach: MySQL's zero date and its TIME that
legitimately exceeds 24 hours, MSSQL's three date/time families and MONEY,
Firebird's negative NUMERIC scale and space-padded CHAR.
Where things are
| file | what for |
|---|---|
ARCHITECTURE.md |
module layout, SourceDialect, migration order |
docs/mage-loader-block.md |
the data_loader block that calls run(), and the frame it returns |
docs/partitioning-existing-table.md |
converting a table that already exists to a partitioned one |
docs/legacy-compat.md |
older configuration shapes, still read |
docs/golden-test.md |
how the core is verified against the predecessor |
CHANGELOG.md |
what changed in which version and what it breaks |
CONTRIBUTING.md |
the three constraints that decide most patches, and how to set up |
SECURITY.md |
credential handling, SSH host keys, where to report |
Contributing
Read CONTRIBUTING.md before writing code. Three constraints decide more patches here than the design does: the runtime cannot be raised, the configuration contract is frozen, and target column names cannot change. None of them are visible from the code alone.
Licence
Apache-2.0 — see LICENSE and NOTICE. The reserved-word
list and the column-name cleaning behaviour reproduced in src/dbextractors/core/naming.py
originate in Mage, also Apache-2.0; the
attribution is in NOTICE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dbextractors-1.0.0.tar.gz.
File metadata
- Download URL: dbextractors-1.0.0.tar.gz
- Upload date:
- Size: 540.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6b46f2d8c183ee4dcd5bca8828dadd0ff65881d2f0a9a46e12304bb33cc30c19
|
|
| MD5 |
027e22db319bbb829581335d5863bf62
|
|
| BLAKE2b-256 |
be027a405c12833e20384fcb66fba69991476d1b1fcf6d334b7068d37704a1f7
|
Provenance
The following attestation bundles were made for dbextractors-1.0.0.tar.gz:
Publisher:
release.yml on bisuperhero/dbextractors
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dbextractors-1.0.0.tar.gz -
Subject digest:
6b46f2d8c183ee4dcd5bca8828dadd0ff65881d2f0a9a46e12304bb33cc30c19 - Sigstore transparency entry: 2486574867
- Sigstore integration time:
-
Permalink:
bisuperhero/dbextractors@adf46e3a603cbf4b43a6ebce87f7bbb984345086 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/bisuperhero
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@adf46e3a603cbf4b43a6ebce87f7bbb984345086 -
Trigger Event:
push
-
Statement type:
File details
Details for the file dbextractors-1.0.0-py3-none-any.whl.
File metadata
- Download URL: dbextractors-1.0.0-py3-none-any.whl
- Upload date:
- Size: 261.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4134cffa7ae603bfcee70e024b1bcc6dc6e16d743cd70bad171f90a1f5d63114
|
|
| MD5 |
02b1fb3a823532540d52b69862ac7cae
|
|
| BLAKE2b-256 |
ddc6bdc08568d3c79e0519c7ad755879b956206a7693238aff900f72b3808f0e
|
Provenance
The following attestation bundles were made for dbextractors-1.0.0-py3-none-any.whl:
Publisher:
release.yml on bisuperhero/dbextractors
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dbextractors-1.0.0-py3-none-any.whl -
Subject digest:
4134cffa7ae603bfcee70e024b1bcc6dc6e16d743cd70bad171f90a1f5d63114 - Sigstore transparency entry: 2486574886
- Sigstore integration time:
-
Permalink:
bisuperhero/dbextractors@adf46e3a603cbf4b43a6ebce87f7bbb984345086 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/bisuperhero
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@adf46e3a603cbf4b43a6ebce87f7bbb984345086 -
Trigger Event:
push
-
Statement type: