conduto
The pipe that carries your data from source to destination.
🇺🇸 English · Português (BR)
conduto is a CLI that scaffolds a complete ELT project in a single command: credentials for both ends in the .env, YAML schemas for the tables, DDL in the target database and Dagster code (assets + schedules) ready to run.
What it's for: you have data in one database and need to keep it synchronized in another. Without conduto that means hand-writing the driver, credentials, cross-database type mapping, CREATE TABLE on the target, incremental reads by watermark, scheduling and orchestration. With conduto you answer a wizard and get a project that loads the data — incremental or full loads, on a schedule, plus an interface to trigger and monitor each table.
- Source and target: PostgreSQL, MySQL, SQL Server, ClickHouse, DuckDB and Delta Lake
- Orchestration: Dagster (independent assets, per-table schedules, web UI)
- Install:
pip install "conduto[postgresql]"(drivers per database) - Code: github.com/joaopedrozg/conduto · Issues: open an issue
Contents
- Installation
- Quick start
- The commands
- The interactive wizard
- What gets generated
- How data is loaded
- Configuration reference
- Supported databases
- Development
Installation
The base package installs only the CLI (Typer, Rich, Textual, Jinja2 and PyYAML). Database drivers come as extras per database — install only what you use:
pip install "conduto[postgresql]" # psycopg
pip install "conduto[mysql]" # pymysql
pip install "conduto[sqlserver]" # pyodbc
pip install "conduto[clickhouse]" # clickhouse-connect
pip install "conduto[duckdb]" # duckdb
pip install "conduto[deltalake]" # deltalake, pyarrow and boto3
pip install "conduto[all]" # every database
Same thing with uv:
uv tool install "conduto[all]"
# or run it without installing it for good
uvx --from "conduto[postgresql]" conduto init my_project
Requirements: Python 3.10+ (the package declares support for 3.10–3.14) and uv on your PATH — conduto uses it to set up the generated project's environment.
Without the extra, the CLI still works fine (
conduto --help,ddl,schedules,docs...). When you pick a database whose driver is missing, conduto asks whether to install it right away (viauvorpip) and prints the manual command if it fails. Projects generated byconduto initget the database driver in their ownuv add— the extra is only for the CLI's environment.
Linux: PEP 668 and the "environment required" error
Debian/Ubuntu, Fedora, Arch and Homebrew on macOS mark the system Python as externally-managed (PEP 668): pip install outside a venv is refused. This does not affect uvx, uv tool or pipx users — they already run in their own venv.
When it happens, conduto detects the marker before installing and shows both ways out:
uvx --from "conduto[postgresql]" conduto # isolated venv, system untouched
pip install --break-system-packages psycopg[binary] # only if you allow it
The prompt "Install on the system Python anyway?" defaults to no — nothing is broken without explicit consent.
Quick start
# 1. Creates the project: connections in .env, schemas/ and main.yml
conduto init my_project
# 2. Generates the table DDL and applies it to the target database
conduto ddl --apply
# 3. Generates the schedules and the Dagster code
conduto schedules
# 4. Starts the Dagster server (http://localhost:3000)
conduto dagster
# 5. Web documentation of the project (http://localhost:8000)
conduto docs
Check the version and the help:
conduto --version
conduto --help
conduto [COMMAND] --help
What conduto init asks
The flow is interactive and guided — you never type database or table names by hand:
- Source database — default port and user change per database
- Source credentials — host, port, user and password
- Connection test — if it fails, retype them or continue anyway
- Databases on the source server — conduto lists them and you pick one
- Target database and credentials — same flow, plus picking the target schema (with an option to create a new database/schema)
- Schema mode — generate automatically (you check the source schemas and tables) or configure manually (generates three examples for you to edit)
- Schedules — whether to generate each table's schedule automatically (default: hourly) and the Dagster code
- Dagster server — whether to start it now and generate the
run_dagster.ps1/run_dagster.shscripts
Inside an existing uv project? If the current directory already has a pyproject.toml (for example after uv add conduto), conduto adapts: it writes .env, main.yml and schemas/ straight into the current project and adds only the missing dependencies — no subfolder, no uv init. In that case use uv run conduto init (the project name becomes optional).
Generating schemas automatically
After both connections are tested, in generate automatically mode conduto:
- lists the source database schemas and opens a selection screen with a search filter — check any number with
space, all visible ones witha(Select all) and clear withl; - then shows the tables of the checked schemas using the same search/check screen — tables that already have
schemas/<table>.ymlshow up in amber as "already exists" (regenerating overwrites); - reads the columns from the database (types, PK, FK, unique, default, nullable) and writes
schemas/*.ymlandmain.yml.
Your checks are what defines the source schema of each table — no other schema prompt shows up afterwards. In manual mode, conduto asks the source schema once and generates the clientes, pedidos and produtos examples.
Adding a new table
Create the schema with just the name (or add the path to main.yml) and let conduto fill in the columns:
# schemas/my_table.yml
table: my_table
conduto inferir # infers every schema without columns
conduto inferir --tabela my_table
conduto schedules # generates schedule and Dagster code for the new one
The command reads the source credentials from the .env, queries the database and writes the columns: while preserving whatever already exists (description, schedule etc.).
The commands
| Command | What it does | Main options |
|---|---|---|
conduto init [NAME] |
Creates/updates the project: .env, schemas/, main.yml, uv environment, schedules and Dagster code |
— |
conduto ddl |
Converts the YAML schemas into CREATE TABLE for the target |
--apply / --no-apply, --output file.sql, --dir |
conduto schedules |
(Re)generates the schema schedules and the Dagster code | --dir |
conduto dagster |
Starts the project's Dagster server (http://localhost:3000) | --dir |
conduto docs |
Starts the project's web documentation (http://localhost:8000) | --port, --host, --no-open, --dir |
conduto inferir |
Infers the columns of the tables in the source database | --tabela, --dir |
conduto install-sqlserver-driver |
Downloads and installs the ODBC Driver for SQL Server (Windows, Linux, macOS) | — |
conduto --help |
General help; conduto [COMMAND] --help shows each command's options |
— |
Global flags: --lang pt|en (language for the run) and --version.
The command names stay in Portuguese (
inferir,ddl,schedules...) — only the messages, prompts and help texts are translated.
Usage examples
# only write the DDL to a file, never touching the database
conduto ddl --no-apply --output ddl.sql
# apply straight away, without prompting (handy for scripts/CI)
conduto ddl --apply
# work in another directory
conduto schedules --dir path/to/project
conduto docs --port 9000 --no-open
Web documentation
conduto docs # opens http://localhost:8000
conduto docs --port 9000 # specific port
conduto docs --no-open # don't open the browser
The page shows the project overview, the file tree, the .env connections (passwords masked), the per-database particularities, the tables with their schemas, the schedules, the generated DDL, the environment and the list of commands.
The interactive wizard
In the terminal, conduto init and conduto ddl run inside a TUI screen (Textual):
- Side menu of steps with the state of each one: current (● blue), done (✓ green), skipped (— gray) or pending (○);
- Review: clicking a completed step shows what was answered there, without rerunning the flow;
- Log panel with what the command is doing, without flashing old logs on every step change;
- Logs in SQLite: all output is kept in
~/.conduto/registros.dband opens withF3, with timestamp, step and scrolling — the output is also replayed in the terminal when the shell closes; - Footer shortcuts:
F2toggles the step menu,F3opens the logs,escgoes back; - Colors are status: green = success, amber = attention, red = error, blue = information, gray = neutral;
- Loading widgets (spinner and progress bar) on slow operations such as the connection test and applying the DDL.
Without an interactive terminal (CI, pipe) or with the CONDUTO_SEM_TUI variable set, everything falls back to the classic numbered prompts in the terminal — same behaviour, no screen.
Logs live at
~/.conduto/registros.db; pointCONDUTO_REGISTROSsomewhere else to change that.
What gets generated
my_project/
├── .env # source and target credentials
├── main.yml # manifest: table list + overall schedule
├── schemas/ # one YAML per table
│ ├── clientes.yml
│ ├── pedidos.yml
│ └── produtos.yml
├── definitions.py # entry point for `dagster dev`
├── conduto_dagster/ # generated Dagster code
│ ├── __init__.py
│ ├── etl.py # connection, read and load of the tables
│ └── definitions.py # assets and schedules built from the YAMLs
├── run_dagster.ps1 # Windows
├── run_dagster.sh # Linux/macOS
└── pyproject.toml # uv environment (pyyaml, jinja2, dagster, dagster-webserver)
Outside a uv project, this structure is created inside
my_project/. Inside an existing uv project, the files go to the current directory. The project is created without ansrc/folder (uv init --bare) — scripts and Dagster code live at the root.
.env — credentials
DB_ORIGEM_TYPE=postgresql
DB_ORIGEM_HOST=localhost
DB_ORIGEM_PORT=5432
DB_ORIGEM_NAME=postgres
DB_ORIGEM_SCHEMA=public
DB_ORIGEM_USER=postgres
DB_ORIGEM_PASSWORD=postgres
DB_DESTINO_TYPE=postgresql
DB_DESTINO_HOST=localhost
DB_DESTINO_PORT=5432
DB_DESTINO_NAME=postgres
DB_DESTINO_SCHEMA=public
DB_DESTINO_USER=postgres
DB_DESTINO_PASSWORD=postgres
# Optional: rows per load batch (default 20000)
# CONDUTO_LOTE=50000
# Optional (PostgreSQL): load statement_timeout in ms (0 = no limit)
# DB_DESTINO_STATEMENT_TIMEOUT=0
Variable names stay in Portuguese: DB_ORIGEM_* holds the source and DB_DESTINO_* the target.
DB_ORIGEM_SCHEMA is the default source schema — the ETL only uses it when the table has no source_schema of its own in the YAML.
Important: the
.envholds credentials and must not be committed.
main.yml — manifest
version: "1.0"
project: my_project
# Schedule of the whole model (every table, in manifest order)
schedule:
cron: "0 * * * *"
tables:
- path: "schemas/clientes.yml"
- path: "schemas/pedidos.yml"
- path: "schemas/produtos.yml"
schemas/*.yml — tables
table: clientes
schema: public # schema in the TARGET (what conduto ddl uses)
source_schema: public # schema in the SOURCE (where the ETL reads from)
description: "Customer registry table"
schedule:
cron: "0 * * * *"
mode: incremental # incremental or full
incremental_column: criado_em
full_load: false # true forces a full load on the next run
truncate: false # true wipes the target table before loading
columns:
- name: id
type: integer
primary_key: true
nullable: false
- name: email
type: varchar(255)
unique: true
- name: criado_em
type: timestamp
default: CURRENT_TIMESTAMP
Two schema keys with different jobs:
| Key | Which schema it is |
|---|---|
schema |
the target — what conduto ddl uses in the CREATE TABLE |
source_schema |
the source — where the ETL reads the table from at load time |
The source one is stored per table because a table can live in one schema and another in a different one (Person, HumanResources, dbo...). Without it the ETL would read everything from the single DB_ORIGEM_SCHEMA in the .env and fail with Invalid object name. In older projects without the key, DB_ORIGEM_SCHEMA still applies as a fallback.
The three examples cover common modelling patterns:
| Schema | What it shows |
|---|---|
clientes.yml |
primary key, unique column and default |
pedidos.yml |
foreign key foreign_key: clientes(id) — documentation only |
produtos.yml |
numeric and boolean types, optional columns (nullable: true) |
No dependencies between tables
foreign_key is model documentation only — nothing in the project turns it into a constraint:
conduto ddlnever emits aFOREIGN KEYin theCREATE TABLE, on any target;- the Dagster code never creates
depsbetween assets; main.ymlis never reordered by FK topology.
So if pedidos references clientes, you can still load pedidos on its own, without clientes existing or having run first. Referential integrity stays with whoever writes to the source.
Custom types
Types that don't exist in every database — ltree, citext, hstore, tsvector, inet, geometry, interval, hierarchyid, year, PostgreSQL arrays etc. — follow a rule per target: the type is kept where it exists natively (PostgreSQL, plus geometry/hierarchyid in SQL Server) and degrades to text the target accepts in the other cases (text/nvarchar(max)/String/varchar/string). That way the CREATE TABLE never fails because of a type.
When the rule isn't the one you want, declare the equivalent on the column with types: — it beats the rule table:
columns:
- name: localizacao
type: geometry # default rule per target
types: # override: only for the listed targets
mysql: point
deltalake: binary
If a type has no rule at all it passes through as-is and conduto warns you — a sign that a types: declaration is worth it.
How data is loaded
The generated Dagster code (conduto_dagster/) reads main.yml and the schemas/*.yml at runtime: changing a schema's schedule key changes the asset/schedule on the next reload, with nothing to regenerate.
Each table is an independent asset, with the mode defined in its own YAML:
| Mode | Behaviour |
|---|---|
incremental |
reads only what is greater than the watermark (last value of incremental_column on the target); with no watermark, loads everything |
full |
wipes the table's target and reloads it entirely |
truncate: true |
wipes the target before loading, whatever mode is in effect |
full_load: true |
forces a full load on the next run even in incremental mode — set it back to false afterwards |
How loading happens per target:
- PostgreSQL:
COPY— direct streamingCOPY (SELECT ...) TO STDOUT→COPY ... FROM STDINwhen source and target are both PostgreSQL (Python only forwards bytes), andCOPY FROM STDINin pre-serialized blocks from other sources. On an already populated table with a PK, the target gets an upsert on the primary key so incremental loads don't raiseUniqueViolation. - ClickHouse: native batched
INSERT. - DuckDB: rows via Arrow +
INSERT SELECT(DuckDB'sexecutemanyis slow, one row at a time). - Delta Lake:
write_deltalake. - SQL Server / MySQL and the rest: batched
INSERT, withfast_executemany(bulk operation) on SQL Server and an automatic fallback if the driver refuses.
The source is always read in batches (fetchmany) with a producer/consumer thread pair — memory stays stable even on huge tables.
Running it
# in the browser (http://localhost:3000): select the assets and click "Materialize"
uv run dagster dev
# or through the generated scripts
.\run_dagster.ps1 # Windows
./run_dagster.sh # Linux/macOS
# or straight from conduto
conduto dagster
conduto dagster --dir path/to/project
dagster dev requires the dagster-webserver package; conduto installs it along with the other dependencies and, if it's missing in an existing project, installs it before starting the server (uv add dagster-webserver). While the server boots, conduto shows an animated status and tells you when it's up. If the conduto_dagster/ code doesn't exist yet it's generated on the spot and the [tool.dagster] block is added to pyproject.toml (recent Dagster versions require it).
Load performance
CONDUTO_LOTEin the.env: rows per copy batch (default20000). On heavy loads50000usually pays off.DB_DESTINO_STATEMENT_TIMEOUT(PostgreSQL, in ms;0= no limit): avoids hitting the server'sstatement_timeoutduringCOPY. Use the session pooler (port5432) or a direct connection — the transaction pooler (6543) doesn't allow sessionSET.- For big loads, drop non-essential indexes/constraints from the target table before a full load and recreate them afterwards —
COPYgets much faster without them.
Configuration reference
Environment variables
| Variable | Where | Effect |
|---|---|---|
CONDUTO_LANG |
shell | forces the CLI language: pt or en (e.g. CONDUTO_LANG=en conduto init) |
CONDUTO_SEM_TUI |
shell | turns the TUI screen off and uses the classic prompts (useful in CI/pipes) |
CONDUTO_REGISTROS |
shell | alternative path for the wizard's SQLite log file (default ~/.conduto/registros.db) |
CONDUTO_LOTE |
project .env |
rows per load batch (default 20000) |
DB_DESTINO_STATEMENT_TIMEOUT |
project .env |
SET statement_timeout on the PostgreSQL target, in ms (0 = no limit) |
Language
conduto detects the machine's language (Portuguese by default, with English support) and uses that preference for messages, prompts, help and labels. Detection order:
CONDUTO_LANG- Locale variables (
LANG,LC_ALL,LC_MESSAGES) and Python's locale - Windows display language
To force it for a single run: conduto --lang en init my_project.
--langapplies to the command's flow;--helpfollows automatic detection and can be forced withCONDUTO_LANG=en conduto --help.
The schedule key (per YAML schema)
| Key | Values | Default |
|---|---|---|
cron |
cron expression for the schedule | 0 * * * * (hourly) |
mode |
incremental or full |
full |
incremental_column |
column used as the watermark | inferred (updated_at, created_at or any other temporal) |
full_load |
true forces a full load on the next run |
false |
truncate |
true wipes the target table before loading |
false |
conduto schedules infers the incremental update column (preferring updated_at/atualizado_em, then created_at/criado_em and finally any temporal column), writes the key into the schemas and keeps the overall model schedule in main.yml. Values you edited by hand are preserved on regeneration — only missing keys get the default.
Supported databases
| Database | Extra | Default port | Default user | Default database |
|---|---|---|---|---|
| PostgreSQL | conduto[postgresql] |
5432 | postgres |
postgres |
| MySQL | conduto[mysql] |
3306 | root |
mysql |
| SQL Server | conduto[sqlserver] |
1433 | sa |
master |
| ClickHouse | conduto[clickhouse] |
8123 | default |
default |
| DuckDB | conduto[duckdb] |
— | — | origem.duckdb (local file) |
| Delta Lake | conduto[deltalake] |
— | minioadmin |
deltalake (S3/MinIO storage) |
Particularities applied automatically in the CLI flow and in the DDL:
- ClickHouse: MergeTree
ENGINE/ORDER BYand no constraints; - Delta Lake: no constraints in the
CREATE TABLE; - MySQL: database == schema;
- SQL Server: automatic download and install of the ODBC driver.
SQL Server ODBC driver
pyodbc needs the native driver on the system. If the connection fails because of it, conduto init offers Install driver automatically — the credentials you typed stay in memory only and the connection test reruns by itself afterwards. You can also install it directly:
conduto install-sqlserver-driver
Works on Windows (winget or MSI), Linux (apt) and macOS (Homebrew). On Windows, if the terminal isn't elevated, conduto brings up the UAC confirmation in front; with a pending reboot, the install is blocked until you restart. There's also a standalone script for manual/offline installs:
.\scripts\install-sqlserver-odbc.ps1 -DownloadOnly -OutFile .\msodbcsql18.msi # download only
.\scripts\install-sqlserver-odbc.ps1 # download and install
Supported versions: 18 (default) and 17 (-Version 17). Official documentation: Download ODBC Driver for SQL Server.
Development
This README also exists in Portuguese, in
README.md— an absolute link on purpose, because this is the file PyPI renders (readme = "README.en.md"inpyproject.toml), and relative links would break there. When you edit one, update the other; CI fails if the two drift apart.
uv sync --all-extras # project + drivers for every database + dev (pytest)
uv run pytest -q # tests
uv run python scripts/check_readme_sync.py # checks both READMEs
uv build # package it
CI (.github/workflows/ci.yml) runs on every push/PR: installs with uv sync --all-extras, smoke-tests the CLI, runs the tests and builds the package.
Publishing a new version
Every merge/push to main triggers the Publish to PyPI workflow, which computes the version from the last one published on PyPI (patch bump; if the PR already bumped pyproject.toml beyond the published one, that version is used), publishes and creates the vX.Y.Z tag. The bump is applied only in the workflow's working tree — it never lands on main.
For a manual release (patch, minor or major): Actions → Publish to PyPI → Run workflow and pick the bump type.
License
Release files for conduto 0.1.42
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| conduto-0.1.42.tar.gz | 190.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| conduto-0.1.42-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 325.4 kB
Release files / conduto-0.1.42.tar.gz
| Download URL | conduto-0.1.42.tar.gz |
|---|---|
| Size | 190.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2da6897eba81504663f4927b60bceb7cd355196091aed1554015f6404b110465
|
|
BLAKE2b-256 checksum How to use checksums |
beefb4dabc9285339117000e81fb8c7b5411a4854a3d0180feeabdf434c09410
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / conduto-0.1.42-py3-none-any.whl
| Download URL | conduto-0.1.42-py3-none-any.whl |
|---|---|
| Size | 135.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e642e302e79bdebb1cac6126ce7b8aedb8bac5ae4db4c6f8c29a861432fb1101
|
|
BLAKE2b-256 checksum How to use checksums |
2c8362b2395906acff0f1e34d2a7e0df88ec4ab7a86382eb8e9844c75f5732d6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|