High-performance tool for database migrations between SQL Server, PostgreSQL, and MySQL — driven from a CLI, an interactive terminal UI, or a browser (--webui), all from a single binary.
dmt runs deterministically with no AI provider required — type mapping, error diagnosis, DB tuning, and runtime parameter adjustment all use built-in rule catalogs. AI features remain available as an opt-in enhancement for vendor-specific edge cases (see Optional AI Enhancements below).
# 1. Create secrets file (no AI section by default; pass --with-ai to opt in)
./dmt init-secrets
# 2. Create a config (interactive)
./dmt init -o my-migration.yaml
# 3. Run the migration
./dmt run --config my-migration.yamlFor interactive use, launch without arguments:
./dmtLaunch the tool without arguments to enter the Interactive Shell, a modern TUI designed for ease of use.
./dmt- Slash Commands: Type
/to see all available commands (e.g.,/run,/preflight,/diagnose,/analyze). The full CLI / TUI / WebUI parity table lives in docs/TUI_COMMANDS.md. - CLI parity:
/run --dry-runpreviews the plan,/preflight --ai-reviewchecks readiness,/validate --ai-triageand/diagnosetriage failures,/ai config-reviewgenerates patch recommendations and a runbook — the same renderers the CLI uses. - Session defaults:
/session KEY VALUEkeeps sticky per-session defaults (config, profile, state-file, observability and audit settings) so they don't need repeating on every command. - Resume: Use
/resumeto continue interrupted migrations (--force-resumeafter config changes). - Auto-Completion:
- Commands: Tab-complete commands like
/validateor/history. - Files: Type
@to browse and select configuration files from your current directory (e.g.,/run @conf<TAB>).
- Commands: Tab-complete commands like
- Configuration Wizard: Type
/wizardto interactively create or edit yourconfig.yaml. It guides you through connection details, SSL settings, and performance tuning. - Live Monitoring: Watch migration progress with real-time logs and visual status indicators.
- Git Integration: View your current branch and repository status directly in the status bar.
Launch a browser-based operator console — the same command surface as the TUI, served as an authenticated single-page app straight from the binary (no separate install, no build step):
./dmt --webui # loopback bind; prints a one-click URL with an auto-generated token
./dmt --gui # same thing, but opens a browser window automatically and acts like a desktop appIt runs the live migration dashboard (progress streamed over Server-Sent Events), readiness checks (preflight/validate/diagnose), the guided setup wizard, profile and session management, and a ⌘K command palette. It calls the same orchestrator the CLI and TUI do — no forked logic.
--gui turns it into a desktop app with no native shell and no new runtime dependency (dmt stays a single pure-Go, CGO_ENABLED=0 binary): it auto-opens a browser, hands off to an already-running instance instead of failing to bind, exits shortly after the window closes (never mid-migration), and is installable as a PWA (Chrome/Edge "Install", Safari "Add to Dock"). See docs/WEBUI.md for details.
Loopback-only by default. A remote/server bind (--webui-addr 0.0.0.0:8484) requires an auth token plus TLS — either native (--webui-tls-cert/--webui-tls-key) or a TLS-terminating reverse proxy (--webui-insecure behind it, optionally with --webui-trusted-proxy for per-client rate-limiting). Brute-force throttling, session renewal, and a strict CSP are on by default. The setup wizard stores DB passwords in ${file:} secret files rather than plaintext.
See docs/WEBUI.md for the security model, remote-deployment guide (nginx/Caddy), and maturity notes (ready for local single-operator use; beta for remote/team).
- Credential redaction - passwords are never stored in the state database or logs
- DSN injection protection - connection strings URL-encode credentials to prevent injection
- SQL injection protection - internal SQLite queries use whitelist validation for table names
- Secret templates - use
${env:VAR}or${file:/path}instead of plaintext passwords in config - Secure permissions - state files (0600) and data directories (0700) enforced automatically
For large databases with frequent updates, use date-based incremental loading to dramatically reduce sync times. Instead of transferring all rows every time, only rows modified since the last sync are transferred.
# Step 1: Initial load (fast bulk copy, creates tables)
./dmt -c config.yaml run # target_mode: drop_recreate
# Step 2: Incremental syncs (uses highwater marks)
./dmt -c config.yaml run # target_mode: upsert| Scenario | Time |
|---|---|
| Full sync (19M rows) | 1m 47s |
| Incremental (no changes) | 12 seconds |
migration:
target_mode: upsert # Required for incremental sync
# Date columns for incremental loading (tries each in order)
# Supported types: datetime, datetime2, timestamp, timestamptz, date
date_updated_columns:
- UpdatedAt # Common convention
- ModifiedDate # SQL Server convention
- LastModified
- CreationDate # Fallback for append-only tables- First run: Full load of all rows, records sync timestamp per table
- Subsequent runs: Only fetches rows where
date_column > last_sync_timestamp - Highwater marks: Stored automatically in the state database (
~/.dmt/migrate.db) - Upsert logic: INSERTs new rows, UPDATEs changed rows, preserves target-only rows
- Upsert requires existing tables - Run
drop_recreatefirst for initial load - Primary keys required - Both source and target tables must have PKs
- Tables without date columns - Fall back to full table comparison (slower)
Continuously monitor migration performance and automatically adjust parameters in real-time. The tuner receives live system resource data (CPU cores, available RAM, connection limits) and makes informed decisions without hard-coded safety guards — an effectiveness tracker measures each adjustment's impact and pauses tuning if consecutive changes hurt performance.
Enable in config.yaml:
migration:
runtime_tuning: true
runtime_tuning_interval: 5s
ai:
api_key: ${ANTHROPIC_API_KEY}
provider: anthropicPre-#211 these fields were named
ai_adjust/ai_adjust_interval. The old names are still accepted but emit a deprecation warning; rename toruntime_tuningto silence it. The runtime tuner is deterministic and rule-based — it does not call out to the AI provider.
-
Continuous Monitoring: Every 30 seconds, collects performance metrics:
- Windowed throughput (rows/sec), memory usage, CPU utilization
- Transfer time breakdown (source query/scan vs. target write percentages)
- Connection pool utilization (active, idle, wait counts per pool)
- Queue depth, active workers, error count
-
Resource-Aware Controller: The rule engine receives live system state:
- CPU cores, available/used RAM, max database connections
- Connection pool saturation (active/idle/waits for source and target)
- Data profile: total tables, total rows, avg row size, estimated pipeline memory
- Table-level progress (complete/failed/remaining)
- Current parameter values and adjustment history
- Effectiveness of previous adjustments
-
Rule-Based Decisions: Analyzes trends and recommends adjustments:
- Scale up: Increase workers or chunk_size if resources available
- Scale down: Reduce workers to minimize lock contention
- Reduce chunk: Decrease batch size if memory pressure detected
- Continue: Maintain current parameters if performance optimal
-
Safety Mechanisms:
- Post-adjustment cooldown (90s) — waits for metrics to stabilize before next adjustment
- Effectiveness tracking — measures throughput change after each adjustment
- Consecutive-negative breaker — pauses tuning after 3 adjustments that hurt performance
- Completion skip — no adjustments when transfer is >90% complete
- Updates applied at chunk boundaries, never mid-transfer
Tested on Stack Overflow 2013 dataset (106.5M rows, MSSQL to PostgreSQL):
Configuration Transfer Overall Throughput
──────────────────────────────────────────────────────────────────────
Smartconfig + runtime tuning 2m 33s 3m 08s 697K rows/sec
Runtime tuning only 5m 14s 5m 50s 339K rows/sec
Smartconfig analyzes source schema, system resources, driver profiles, and completed-run history to set initial parameters. Runtime tuning monitors live metrics (throughput, CPU, memory, pool utilization, transfer time breakdown) and adjusts parameters mid-migration through the rule-based controller.
migration:
runtime_tuning: true # Enable/disable rule-based runtime controller (default: true)
runtime_tuning_interval: 5s # Evaluation interval (default: 5s)
# Initial parameters (runtime tuner adjusts from here)
chunk_size: 10000
workers: 4
read_ahead_buffers: 8
write_ahead_writers: 2
parallel_readers: 2
ai:
api_key: ${ANTHROPIC_API_KEY}
provider: anthropic # Also: openai, gemini, ollama, lmstudio
model: claude-sonnet-5 # Recommended default
timeout_seconds: 30- API Calls: ~1-2 per minute (60s cache + 90s cooldown between adjustments)
- Cost: Model-dependent; AI calls are sparse and cached
- Fallback: Heuristic rules apply if AI unavailable
Runtime tuning not happening:
- Check logs for the controller-startup message
- Verify
runtime_tuning: truein config (legacy aliasai_adjuststill works) - Tuner skips adjustments when >90% complete or during the post-adjustment cooldown
Performance degradation:
- The effectiveness tracker will automatically pause after consecutive negative adjustments
- Check
--verbosity debuglogs for adjustment-effect measurements - Disable with
runtime_tuning: falseto use fixed parameters
You can store full configuration profiles (including secrets) encrypted at rest inside the same SQLite database used for run history.
Master key
- Set
DMT_MASTER_KEYto a base64-encoded 32-byte key. - Example key generation (POSIX):
openssl rand -base64 32
- Without this key, profile operations will fail, but YAML-based workflows continue to work.
CLI workflow
# Save a profile from YAML (encrypts and stores in SQLite)
DMT_MASTER_KEY=... ./dmt profile save --name prod --config config.yaml
# List profiles
DMT_MASTER_KEY=... ./dmt profile list
# Run using a profile
DMT_MASTER_KEY=... ./dmt run --profile prod
# Export a profile back to YAML
DMT_MASTER_KEY=... ./dmt profile export --name prod --out config.yamlYAML profile name (optional)
profile:
name: prod
description: |
Production profile for nightly migrations.
Uses MSSQL source and PostgreSQL target.If profile.name is present, profile save can infer the name when --name is omitted.
Descriptions are shown in profile list.
TUI workflow
/profile save prod @config.yaml
/profile save @config.yaml # infers name from profile.name or filename
/profile list
/run --profile prod
/profile export prod @config.yaml
Airflow note
- Profiles are stored as encrypted blobs in the same SQLite DB (
~/.dmt/migrate.dbby default). - In Airflow, you can set
DMT_MASTER_KEYvia your secrets backend and runprofile saveat deploy time, or stick with YAML + env vars for CI/CD. - You can relocate the SQLite DB by setting
migration.data_dirin your config (e.g., to a shared volume). - On first run, the default data directory (
~/.dmt) is created automatically if it does not exist.
dmt's core migration path is fully deterministic — type mapping, error diagnosis, DB tuning, and runtime parameter adjustment all work without AI. The features described here are optional enhancements for cases the deterministic catalog doesn't cover (vendor-specific column types like Oracle hierarchyid or MSSQL geography).
To opt in (two safe paths):
- Brand-new install — run
dmt init-secrets --with-aito seed the AI provider section at template-creation time. - Existing secrets file — APPEND an
ai:block to your~/.secrets/dmt-config.yaml. Do NOT run--force --with-ai; that would overwrite yourmaster_keyand Slack webhook values.
Example snippet to append:
ai:
default_provider: anthropic
providers:
anthropic:
api_key: "" # Get from https://console.anthropic.com/
model: "claude-sonnet-5"All AI features share common configuration under the ai section.
Simply add your API key to enable AI features:
ai:
api_key: ${ANTHROPIC_API_KEY} # or OPENAI_API_KEY or GEMINI_API_KEYThis auto-enables AI type mapping. Provider defaults to Anthropic.
| Provider | Config Value | Default Model | API Key Variable |
|---|---|---|---|
| Anthropic (default) | anthropic |
claude-sonnet-5 |
ANTHROPIC_API_KEY |
| OpenAI | openai |
gpt-5.5 |
OPENAI_API_KEY |
| Google Gemini | gemini |
gemini-2.0-flash |
GEMINI_API_KEY |
| Ollama (local) | ollama |
- | - |
| LM Studio (local) | lmstudio |
- | - |
ai:
api_key: ${ANTHROPIC_API_KEY} # Required - your API key
provider: anthropic # Optional - anthropic (default), openai, gemini, ollama, lmstudio
model: claude-sonnet-5 # Optional - uses provider default if not set
type_mapping:
enabled: true # Auto-enabled when api_key is set
cache_file: ~/.dmt/type-cache.jsonAutomatically infers the best target type for unknown or complex source types.
How it works:
- Data Sampling: Samples up to 5 rows from each table for context
- Intelligent Inference: AI analyzes column metadata plus sample values
- Cross-Engine Awareness: Understands encoding differences (e.g., PG varchar → MSSQL nvarchar)
- Caching: Mappings cached to minimize API calls
When to use:
- Custom domains, user-defined types, or database-specific types
- Cross-engine migrations with different type systems
- Unicode handling (AI correctly infers
nvarcharfor UTF-8 text)
Analyze your source database and get optimal configuration suggestions:
./dmt -c config.yaml analyzeOutput example:
# AI-detected configuration suggestions
migration:
date_updated_columns:
- UpdatedAt
- ModifiedDate
- LastModified
exclude_tables:
- temp_imports
- audit_log
- __EFMigrationsHistory
chunk_size: 150000What it detects:
- Date columns: Columns suitable for incremental sync (UpdatedAt, ModifiedDate, etc.)
- Exclude tables: Tables that should probably be excluded (temp, log, archive, etc.)
- Chunk size: Optimal chunk size based on average row sizes
When a table transfer fails, DMT uses its deterministic diagnosis catalog to provide actionable suggestions. Unmatched errors are counted in observability as catalog-growth candidates; they are not sent to an AI provider.
Example output:
Table Orders failed: pq: invalid input syntax for type integer: "abc"
Diagnosis:
Cause: Data type mismatch - column contains non-numeric values being inserted into integer column
Suggestions:
- Check source data for non-numeric values in numeric columns
- Use TEXT type instead of INTEGER for this column
- Add data transformation to filter/convert invalid values
Confidence: high
Features:
- Automatic: Runs automatically when a transfer fails
- Context-aware: Uses table schema, column types, and source/target DB info when available
- Local: No error text is sent to an AI provider
- Categorized: Errors classified as type_mismatch, constraint, permission, connection, or data_quality
Common diagnoses:
| Error Type | Diagnosis |
|---|---|
| Type mismatch | Identifies incompatible column types and suggests mappings |
| NULL constraint | Detects NULL values in NOT NULL columns |
| Foreign key | Identifies missing parent records or ordering issues |
| Permission | Suggests required grants or role assignments |
| Connection | Diagnoses timeout, authentication, or network issues |
Requirements:
- AI must be configured (
ai.api_keyset) - Works with all supported providers (Claude, OpenAI, Gemini)
- Each unique type mapping requires one API call (cached for future runs)
- Typical migration: 20-50 API calls on first run, zero on subsequent runs
- Smart config analysis: 0 API calls (uses pattern matching, not AI)
- Error diagnosis: 1 API call per unique error (cached to avoid duplicates)
For headless environments like Airflow or Kubernetes where SQLite may be impractical, you can use a YAML-based state file instead.
# Use a YAML state file instead of SQLite
./dmt -c config.yaml --state-file /tmp/migration-state.yaml run
# Resume using the same state file
./dmt -c config.yaml --state-file /tmp/migration-state.yaml resume
# Check status
./dmt -c config.yaml --state-file /tmp/migration-state.yaml status
# View history
./dmt -c config.yaml --state-file /tmp/migration-state.yaml historyState file features:
- Portable - Single YAML file, easy to store in cloud storage or shared volumes
- Human-readable - Inspect and debug migration state directly
- Chunk-level resume - Same resume granularity as SQLite backend
- Error tracking - Failed runs store the error message for debugging
Example state file:
run_id: a1b2c3d4
started_at: 2025-01-15T10:30:00Z
completed_at: 2025-01-15T10:45:00Z
status: success
source_schema: dbo
target_schema: public
config_hash: 2bd314ff9b5251d5
config_path: /path/to/config.yaml
tables:
transfer:dbo.Users:
status: success
last_pk: 2465713
rows_done: 2465713
rows_total: 2465713
task_id: 1001
transfer:dbo.Posts:
status: success
last_pk: 17142169
rows_done: 17142169
rows_total: 17142169
task_id: 1002When to use state file vs SQLite:
| Feature | SQLite (default) | State File (--state-file) |
|---|---|---|
| History | Full run history | Current run only |
| Profiles | Encrypted storage | Not supported |
| Best for | Desktop, TUI | Airflow, Kubernetes, CI/CD |
| Persistence | Local database | Any storage (S3, NFS, etc.) |
The CLI provides first-class support for Airflow with machine-readable outputs and deterministic run IDs.
| Flag | Description |
|---|---|
--run-id <id> |
Explicit run ID (default: auto-generated UUID). Use {{ dag_run.run_id }} in Airflow. |
--output-json |
Output JSON result to stdout on completion (logs go to stderr) |
--output-file <path> |
Write JSON result to file on completion |
--log-format=json |
Structured JSONL logging (one JSON object per line) |
status --json |
Output current status as JSON (for Airflow sensors) |
--force-resume |
Bypass config hash validation on resume |
resume --abandon |
Fenced, explicit abandonment of the resumable run for the configured target |
resume --abandon-reason <text> |
Persist the operator reason with --abandon |
from airflow import DAG
from airflow.operators.bash import BashOperator
from datetime import datetime
with DAG('mssql_to_pg_migration', start_date=datetime(2025, 1, 1)) as dag:
migrate = BashOperator(
task_id='migrate_data',
bash_command='''
/opt/dmt \
--run-id "{{ dag_run.run_id }}" \
--output-json \
--output-file /tmp/{{ dag_run.run_id }}_result.json \
--log-format=json \
--state-file /tmp/{{ dag_run.run_id }}_state.yaml \
-c /opt/configs/migration.yaml \
run
''',
do_xcom_push=True, # Captures stdout JSON for downstream tasks
)from airflow.providers.cncf.kubernetes.operators.kubernetes_pod import KubernetesPodOperator
migrate = KubernetesPodOperator(
task_id='migrate_data',
name='dmt',
image='your-registry/dmt:latest',
cmds=['/dmt'],
arguments=[
'--run-id', '{{ dag_run.run_id }}',
'--output-json',
'--log-format', 'json',
'--state-file', '/data/state.yaml',
'-c', '/config/migration.yaml',
'run'
],
volumes=[...],
volume_mounts=[...],
get_logs=True,
do_xcom_push=True,
)Migration Result (--output-json / --output-file):
{
"run_id": "dag_2025_01_15",
"status": "success",
"started_at": "2025-01-15T10:00:00Z",
"completed_at": "2025-01-15T10:01:34Z",
"duration_seconds": 94,
"tables_total": 9,
"tables_success": 9,
"tables_failed": 0,
"rows_transferred": 19310703,
"rows_per_second": 205432,
"failed_tables": [],
"table_stats": [
{"name": "Users", "rows": 299398, "status": "success"},
{"name": "Posts", "rows": 3729195, "status": "success"}
]
}Status Result (status --json - for Airflow sensors):
{
"run_id": "dag_2025_01_15",
"status": "running",
"phase": "transferring",
"started_at": "2025-01-15T10:00:00Z",
"tables_total": 9,
"tables_complete": 5,
"tables_running": 2,
"tables_pending": 2,
"rows_transferred": 12500000,
"progress_percent": 65
}JSON Log Format (--log-format=json - JSONL to stderr):
{"ts":"2025-01-15T10:00:01Z","level":"info","msg":"Starting migration run: dag_2025_01_15"}
{"ts":"2025-01-15T10:00:02Z","level":"info","msg":"Found 9 tables"}
{"ts":"2025-01-15T10:00:03Z","level":"info","msg":"Transferring data..."}When using --state-file, the tool stores a hash of the config at run start. On resume:
- If the config has changed, resume is blocked with an error showing both hashes
- Use
--force-resumeto bypass this check (useful for intentional config tweaks) - This prevents accidentally resuming with mismatched source/target settings
# Config changed error
Error: config changed since run started (hash 6abfe692 != 1cddb8e0), use --force-resume to override
# Force resume anyway
./dmt --state-file state.yaml -c config.yaml resume --force-resumePoll migration status from a separate task:
from airflow.sensors.python import PythonSensor
import subprocess
import json
def check_migration_status(**context):
result = subprocess.run([
'/opt/dmt',
'--state-file', f"/tmp/{context['dag_run'].run_id}_state.yaml",
'-c', '/opt/configs/migration.yaml',
'status', '--json'
], capture_output=True, text=True)
status = json.loads(result.stdout)
if status['status'] == 'success':
return True
elif status['status'] == 'failed':
raise Exception(f"Migration failed: {status.get('error')}")
return False # Still running
sensor = PythonSensor(
task_id='wait_for_migration',
python_callable=check_migration_status,
poke_interval=60,
timeout=3600,
)- 222K-697K rows/sec depending on direction and row width
- MSSQL → PG: 697K rows/sec with smartconfig + runtime tuning (106.5M rows in 2m33s)
- PG → MSSQL: 645K rows/sec (PG streaming + TDS bulk copy)
- PG → PG: 563K rows/sec (COPY protocol both ends)
- MSSQL → MSSQL: 222K rows/sec (TDS both ends)
- Auto-tuning based on CPU cores, available RAM, completed-run history, and rule-based runtime adjustments
- Single binary - no runtime dependencies, no CGO
| Database | As Source | As Target | Write Method | Auth |
|---|---|---|---|---|
| PostgreSQL | ✓ | ✓ | COPY protocol (fastest) | Password |
| SQL Server | ✓ | ✓ | TDS bulk copy | Password |
| MySQL | ✓ | ✓ | Multi-row INSERT | Password |
Kerberos / SPNEGO is not currently supported — the DSN-building plumbing exists but is not wired to the runtime drivers, and we don't yet have a Kerberized integration environment to verify against. See #251 for the re-enable plan.
All combinations are supported, including same-engine migrations (PG→PG, MSSQL→MSSQL, MySQL→MySQL).
Use cases: database cloning, environment sync (dev → staging → prod), disaster recovery, data center migrations.
source:
type: postgres
host: source-pg.example.com
target:
type: postgres
host: target-pg.example.com
migration:
target_mode: upsert # or drop_recreateCross-engine migrations (PG→MSSQL) preserve spatial reference systems:
- SRID preservation - reads SRID from PostGIS
geometry_columns/geography_columnsmetadata - Automatic conversion - WKT text converted to SQL Server geography/geometry with correct SRID
- Default fallback - uses SRID 4326 (WGS84) when source SRID is 0 or unset
- PostgreSQL, SQL Server, MySQL - migrate between any combination
- Bulk copy protocols - PostgreSQL COPY, TDS bulk copy, MySQL LOAD DATA for maximum throughput
- SSL/TLS encryption - configurable per connection
- Pipelined I/O - read-ahead buffering with parallel writers (222K-697K rows/sec)
- Keyset pagination - efficient partitioning for integer PKs (no OFFSET degradation)
- ROW_NUMBER pagination - automatic fallback for composite/varchar PKs
- Parallel partitioning - large tables split via NTILE for concurrent transfer
- Auto-tuning - workers and pools sized from CPU, with target-aware chunk sizing constrained by the memory budget
- Memory-bounded - configurable memory cap (default: 70% available RAM)
- Multi-provider - Claude, OpenAI, Gemini, Ollama, LM Studio
- Type mapping - LLM-powered cross-database type inference with caching
- Smart config analysis - analyze source database and recommend optimal parameters (
analyzecommand) - Real-time parameter tuning - monitor performance and auto-adjust workers, chunk size, buffers mid-migration
- Error diagnosis - AI-powered root cause analysis with remediation suggestions
- Chunk-level resume - progress saved every N chunks, resume from exact position
- Table-level resume - skip already-completed tables on restart
- Idempotent retries - partition cleanup on retry prevents duplicates
- State backends - SQLite (default) or YAML file for Airflow/headless environments
- Config validation - prevents resume with mismatched configuration
- Upsert mode - INSERT new rows, UPDATE changed rows, preserve target-only data
- Date-based highwater marks - only transfer rows modified since last sync
- Configurable date columns - tries multiple column names in order
- Slash commands -
/run(incl.--dry-run),/resume,/preflight,/validate,/diagnose,/analyze,/ai config-review,/session,/init-secrets,/cache clear, and more — see docs/TUI_COMMANDS.md for the CLI parity table - Auto-completion - tab-complete commands and
@file browser for config selection - Configuration wizard - interactive setup for source, target, and tuning parameters
- Live monitoring - real-time migration progress with log capture
- Commands -
run,resume,status,validate,history,health-check,analyze,init,profile - Dry-run mode - preview migration plan without execution
- JSON output - structured results to stdout or file for orchestration tools
- Exit codes - semantic codes for retry logic (success, transient, config error, cancelled)
- Airflow integration - YAML state files, explicit run IDs, BashOperator/KubernetesPodOperator support
- Graceful shutdown - SIGINT/SIGTERM handling with configurable timeout and checkpoint save
- Encrypted profiles - AES-encrypted configs stored in SQLite with master key
- Secret templates -
${env:VAR},${file:/path}for credentials (no plaintext in config) - Credential redaction - passwords never stored in state database or logs
- Secure permissions - 0600 files, 0700 directories enforced automatically
- Full schema transfer - tables, primary keys, indexes, foreign keys, check constraints
- Identity/sequence reset - preserves auto-increment values after transfer
- Table filtering - include/exclude tables with glob patterns
- Strict consistency mode - table locks instead of NOLOCK for consistent reads
- Progress bar - real-time throughput stats with ETA
- Slack notifications - start, completion, and failure alerts
- JSON progress - streaming updates to stderr for monitoring dashboards
- Verbosity levels - debug, info, warn, error with text or JSON log format
- Row count validation - automatic post-transfer verification
- Sample data validation - random row verification with composite PK support
- Single binary - no runtime dependencies, no CGO
- Cross-platform - Linux, macOS (Intel + Apple Silicon), Windows
- YAML configuration - with environment variable expansion
Download from GitHub Releases.
Releases ship raw binaries per platform plus a checksums.txt:
# Linux x64
curl -LO https://github.com/johndauphine/dmt/releases/download/v5.4.0/dmt-v5.4.0-linux-amd64
chmod +x dmt-v5.4.0-linux-amd64 && ./dmt-v5.4.0-linux-amd64 --version
# macOS Apple Silicon
curl -LO https://github.com/johndauphine/dmt/releases/download/v5.4.0/dmt-v5.4.0-darwin-arm64
chmod +x dmt-v5.4.0-darwin-arm64 && ./dmt-v5.4.0-darwin-arm64 --version
# macOS Intel
curl -LO https://github.com/johndauphine/dmt/releases/download/v5.4.0/dmt-v5.4.0-darwin-amd64
chmod +x dmt-v5.4.0-darwin-amd64
# Windows (PowerShell)
Invoke-WebRequest -Uri https://github.com/johndauphine/dmt/releases/download/v5.4.0/dmt-v5.4.0-windows-amd64.exe -OutFile dmt.exe
# Verify a download (any platform)
curl -LO https://github.com/johndauphine/dmt/releases/download/v5.4.0/checksums.txt
shasum -a 256 -c --ignore-missing checksums.txtRequires Go 1.25.7+
git clone https://github.com/johndauphine/dmt.git
cd dmt
CGO_ENABLED=0 go build -o dmt ./cmd/dmtgo install github.com/johndauphine/dmt/v5/cmd/dmt@latestThe /v5 module suffix makes @latest resolve to v5 release tags, and this
installs a binary named dmt. Immutable tags published before this fix cannot
be repaired; use the first v5 release published after this change (or a newer
one). For a pinned, reproducible install, prefer a specific version or the
release binaries.
- Create a
config.yaml:
source:
type: mssql # optional, default for source
host: sqlserver.example.com
port: 1433
database: MyDatabase
user: sa
password: ${MSSQL_PASSWORD}
schema: dbo
target:
type: postgres # optional, default for target
host: postgres.example.com
port: 5432
database: mydb
user: postgres
password: ${PG_PASSWORD}
schema: public
migration:
workers: 8
chunk_size: 200000source:
type: postgres
host: postgres.example.com
port: 5432
database: mydb
user: postgres
password: ${PG_PASSWORD}
schema: public
target:
type: mssql
host: sqlserver.example.com
port: 1433
database: MyDatabase
user: sa
password: ${MSSQL_PASSWORD}
schema: dbo
migration:
workers: 8
chunk_size: 200000SQL Server target uses TDS Bulk Copy protocol (mssql.CopyIn) for optimal performance (~130,000 rows/sec).
- Run the migration:
./dmt -c config.yaml run- If interrupted, resume:
./dmt -c config.yaml resumeThe configuration file uses YAML format. Environment variables can be used with ${VAR_NAME} syntax.
The source section configures the database to migrate FROM.
| Parameter | Required | Default | Description |
|---|---|---|---|
type |
No | mssql |
Database type: mssql, postgres, or mysql |
host |
Yes | - | Database server hostname or IP address |
port |
No | Auto | Database server port (1433/5432/3306) |
database |
Yes | - | Database name |
user |
Yes | - | Username for authentication |
password |
Yes | - | Password for authentication. Supports ${ENV_VAR} syntax |
schema |
No | Auto | Schema containing tables to migrate |
SSL/TLS Settings (source):
| Parameter | Required | Default | Description |
|---|---|---|---|
ssl_mode |
No | require |
PostgreSQL SSL mode: disable, require, verify-ca, verify-full. MySQL accepts the same names plus preferred for explicit downgradeable TLS. |
encrypt |
No | true |
SQL Server encryption: true or false |
trust_server_cert |
No | false |
SQL Server: Skip certificate validation (use only for testing) |
packet_size |
No | 32767 |
SQL Server TDS packet size in bytes (max: 32767). Larger packets improve throughput. |
Kerberos / SPNEGO auth is not currently supported (see #251). Setting auth: kerberos is rejected at config-load. The auth, krb5_conf, keytab, realm, spn, and gssencmode fields are reserved in the schema for the eventual re-enable.
The target section configures the database to migrate TO. It uses the same parameters as source.
| Parameter | Required | Default | Description |
|---|---|---|---|
type |
No | postgres |
Database type: mssql, postgres, or mysql |
host |
Yes | - | Database server hostname or IP address |
port |
No | Auto | Database server port (5432/1433/3306) |
database |
Yes | - | Database name |
user |
Yes | - | Username for authentication |
password |
Yes | - | Password for authentication |
schema |
No | Auto | Target schema for migrated tables |
The same SSL/TLS settings are available for target.
The migration section controls how data is transferred.
Connection Pool Settings:
| Parameter | Required | Default | Description |
|---|---|---|---|
max_source_connections |
No | Auto-sized | Maximum source database connection pool size |
max_target_connections |
No | Auto-sized | Maximum target database connection pool size |
Parallelism Settings:
| Parameter | Required | Default | Description |
|---|---|---|---|
workers |
No | CPU-based (4-12) | Number of parallel transfer workers. The load-time formula uses 4 workers through 6 cores, cores - 2 through 13 cores, and 12 workers above that. |
chunk_size |
No | RAM-shaped (50,000-200,000 before safety clamps) | Rows per chunk. The load-time formula starts at 75,000 and scales with available memory; the retained memory envelope may reduce it, and pre-transfer tuning may replace generated values after schema/history analysis. |
max_partitions |
No | Same as effective workers |
Maximum partitions for large table parallelism |
large_table_threshold |
No | 5,000,000 | Tables with more rows than this are partitioned |
Omitted performance values are initialized by the legacy load-time formulas, then the schema- and history-aware tuner may replace generated values before a run. Explicit user or secrets values remain pinned against policy tuning. Before transfer, representative-width memory and target-protocol limits may reduce the global chunk, including a pinned value. During steady transfer, measured chunk bytes share one admission budget and MemoryGuard is the backstop. The complete-inventory table model gates runtime writer-count transitions and ratchets later chunk or batch growth only after a transition is applied.
Table Filtering:
| Parameter | Required | Default | Description |
|---|---|---|---|
include_tables |
No | All tables | List of glob patterns for tables to include (e.g., Users, Order*) |
exclude_tables |
No | None | List of glob patterns for tables to exclude (e.g., temp_*, __*) |
Target Table Handling:
| Parameter | Required | Default | Description |
|---|---|---|---|
target_mode |
No | drop_recreate |
How to handle existing tables: drop_recreate (drop and recreate) or upsert (incremental sync). Note: upsert requires target tables to already exist - run drop_recreate first for initial load. |
date_updated_columns |
No | None | List of column names to check for last-modified date (e.g., UpdatedAt, ModifiedDate). Enables incremental sync - only rows modified since last sync are transferred. |
data_dir |
No | ~/.dmt |
Directory for state database and temporary files |
Schema Object Creation:
| Parameter | Required | Default | Description |
|---|---|---|---|
create_indexes |
No | true |
Create non-primary key indexes after data transfer |
create_foreign_keys |
No | true |
Create foreign key constraints after data transfer |
create_check_constraints |
No | true |
Create CHECK constraints after data transfer |
fail_on_schema_drift |
No | false |
Abort before transfer if the source schema differs from the last successful run's snapshot. By default, dmt reports drift and continues. |
Consistency Settings:
| Parameter | Required | Default | Description |
|---|---|---|---|
strict_consistency |
No | false |
Read source data from an engine-native stable view. PostgreSQL, MySQL/MariaDB, and SQL Server support parallel readers; SQLite intentionally uses one reader. |
strict_consistency_scope |
No | table |
table creates an independent view per table. migration shares one PostgreSQL exported snapshot or SQL Server database snapshot across every table and partition. Requires strict_consistency: true. |
Consistency scope (#640).
strict_consistencystarts a source transaction before target preparation, then routes every page of that table through one stable view. PostgreSQL imports an exported MVCC snapshot into parallel reader transactions without blocking writers. InnoDB MySQL/MariaDB starts parallel repeatable-read sessions inside a briefLOCK TABLESwindow, which requiresLOCK TABLESprivilege and pauses table writes only while the sessions start. SQL Server holds one shared table lock while parallel readers copy the table, so writes to that table wait for its transfer to finish. SQLite uses one serializable reader by design. Table scope keeps one unpartitioned job per table because these views do not span independent jobs; eligible integer and composite keysets can still use parallel readers inside that job. Unsupported sources fail before target mutation. The guarantee is per table, not one snapshot shared across independently transferred tables.Migration scope (#663, #683). PostgreSQL exports one MVCC snapshot before the transfer phase and imports it into every table reader and partition. SQL Server creates one run-scoped database snapshot and routes table reads and partition planning through it; live writers remain unblocked while changed pages consume copy-on-write disk. Both mechanisms provide one cross-table source instant and safely enable strict partition jobs. PostgreSQL resume in a new process opens a new epoch; a SQL Server snapshot survives a crash and is reused on resume, which fails closed if that snapshot is missing. MySQL and SQLite do not support migration scope. See Restartability for lifecycle, prerequisite, and blocking details.
Strict validation (#664). For a full-table strict transfer, dmt captures
COUNT(*)through that same pinned source transaction before it prepares the target. Validation compares the target with this persisted snapshot count, not with a later live source count. If the live source has changed since the snapshot, dmt reports the signed drift as informational and still passes when the target matches the snapshot.dmt validateuses the latest matching run's stored strict evidence. Incremental jobs with a date filter transfer a window rather than a whole table, so they retain the normal live-count validation behavior.
Validation Settings:
| Parameter | Required | Default | Description |
|---|---|---|---|
sample_validation |
No | false |
Enable random row sampling to verify data integrity |
sample_size |
No | 100 | Number of random rows per table to verify |
Performance Tuning:
| Parameter | Required | Default | Description |
|---|---|---|---|
read_ahead_buffers |
No | Memory-shaped (4-32) | Number of chunks to buffer ahead of writers |
write_ahead_writers |
No | Target- and CPU-scaled (minimum 1) | Parallel writers per job; pre-transfer tuning may replace the generated value |
parallel_readers |
No | CPU-scaled (minimum 2) | Parallel readers per job. Pin to 1 for local databases when appropriate |
source.chunk_size |
No | Same as migration.chunk_size |
Batch size for reading from source database |
target.chunk_size |
No | Same as migration.chunk_size |
Batch size for writing to target database |
The ai section configures AI-powered features.
| Parameter | Required | Default | Description |
|---|---|---|---|
ai.api_key |
Yes (if using AI) | - | API key for the AI provider |
ai.provider |
No | anthropic |
AI provider: anthropic, openai, gemini, ollama, or lmstudio |
ai.model |
No | Provider default | Model to use (e.g., claude-sonnet-5, gpt-5.5, gemini-2.0-flash) |
ai.timeout_seconds |
No | 30 |
API request timeout |
ai.type_mapping.enabled |
No | Auto | Enable AI type mapping (auto-enabled when api_key is set) |
ai.type_mapping.cache_file |
No | ~/.dmt/type-cache.json |
Path to cache AI type mappings |
ai.smart_config.enabled |
No | false |
Enable smart config detection |
ai.smart_config.detect_date_columns |
No | true |
Detect date_updated_columns candidates |
ai.smart_config.detect_exclude_tables |
No | true |
Detect tables to exclude |
ai.smart_config.suggest_chunk_size |
No | true |
Suggest optimal chunk size |
| Parameter | Required | Default | Description |
|---|---|---|---|
enabled |
No | false |
Enable Slack notifications |
webhook_url |
Yes (if enabled) | - | Slack incoming webhook URL |
channel |
No | Webhook default | Channel to post to (e.g., #data-engineering) |
username |
No | dmt |
Bot username for messages |
Not currently supported — tracked in #251. The auth, krb5_conf, keytab, realm, spn, and gssencmode fields are reserved in the YAML schema for the eventual re-enable; auth: kerberos is rejected at config-load until a verifiable Kerberized test environment is in place.
Ready-to-use example configuration files are available in the examples/ directory:
| File | Description |
|---|---|
config-mssql-to-pg.yaml |
SQL Server → PostgreSQL with password auth |
config-pg-to-mssql.yaml |
PostgreSQL → SQL Server with password auth |
config-local.yaml |
Minimal config for local Docker development |
config-production.yaml |
Full production config with all options |
Basic migration from SQL Server to PostgreSQL using username/password:
# config-mssql-to-pg.yaml
source:
type: mssql
host: sqlserver.example.com
port: 1433
database: SourceDatabase
user: sa
password: ${MSSQL_PASSWORD} # Set via: export MSSQL_PASSWORD="your-password"
schema: dbo
encrypt: "true" # Enable encryption (recommended)
trust_server_cert: false # Validate server certificate
target:
type: postgres
host: postgres.example.com
port: 5432
database: target_db
user: postgres
password: ${PG_PASSWORD} # Set via: export PG_PASSWORD="your-password"
schema: public
ssl_mode: require # Enable SSL (recommended)
migration:
workers: 8 # Parallel workers
chunk_size: 200000 # Rows per chunk
create_indexes: true # Recreate indexes
create_foreign_keys: true # Recreate foreign keys
target_mode: drop_recreate # Drop and recreate tables
fail_on_schema_drift: false # Report source schema drift and continueReverse migration from PostgreSQL to SQL Server:
# config-pg-to-mssql.yaml
source:
type: postgres
host: postgres.example.com
port: 5432
database: source_db
user: postgres
password: ${PG_PASSWORD}
schema: public
ssl_mode: require
target:
type: mssql
host: sqlserver.example.com
port: 1433
database: TargetDatabase
user: sa
password: ${MSSQL_PASSWORD}
schema: dbo
encrypt: "true"
trust_server_cert: false
migration:
workers: 8
chunk_size: 200000
write_ahead_writers: 8 # Use 8 writers for PG→MSSQL (faster)
parallel_readers: 1 # Single reader per job
create_indexes: true
create_foreign_keys: trueSimplest config for local Docker databases:
# config-local.yaml
source:
host: localhost
port: 1433
database: MyDatabase
user: sa
password: ${MSSQL_PASSWORD}
encrypt: "false" # Disable encryption for local dev
trust_server_cert: true # Trust self-signed certs
target:
host: localhost
port: 5432
database: mydb
user: postgres
password: ${PG_PASSWORD}
ssl_mode: disable # Disable SSL for local devFull production configuration with Slack notifications and validation:
# config-production.yaml
source:
type: mssql
host: sqlserver-prod.example.com
port: 1433
database: ProductionDB
user: migrate_user
password: ${MSSQL_PASSWORD}
schema: dbo
encrypt: "true"
trust_server_cert: false
target:
type: postgres
host: postgres-prod.example.com
port: 5432
database: production_db
user: migrate_user
password: ${PG_PASSWORD}
schema: public
ssl_mode: verify-full # Full certificate verification
migration:
# Connection pools
max_source_connections: 20
max_target_connections: 40
# Parallelism
workers: 16
chunk_size: 250000
max_partitions: 16
large_table_threshold: 10000000
# Table filtering
exclude_tables:
- temp_*
- staging_*
- __*
- audit_log
# Schema objects
create_indexes: true
create_foreign_keys: true
create_check_constraints: true
# Consistency
strict_consistency: true # Pin each table to one stable source transaction
# Validation
sample_validation: true # Verify random samples
sample_size: 500 # Check 500 rows per table
# State persistence
data_dir: /var/lib/dmt
slack:
enabled: true
webhook_url: ${SLACK_WEBHOOK_URL}
channel: "#data-migrations"
username: dmt
# Run a new migration
./dmt -c config.yaml run
# Dry-run (preview plan without executing)
./dmt -c config.yaml run --dry-run
# Resume an interrupted migration (continues from last checkpoint)
./dmt -c config.yaml resume
# Check status of current/last run
./dmt -c config.yaml status
# Validate row counts between source and target
./dmt -c config.yaml validate
# View migration history
./dmt -c config.yaml history
# View details for a specific run
./dmt -c config.yaml history --run <run-id>
# Test database connections
./dmt -c config.yaml health-check
# Analyze source database and get AI configuration suggestions
./dmt -c config.yaml analyze
# Create a new config file interactively
./dmt init
# Create a secrets file template
./dmt init-secretsFor headless environments, use --state-file to store state in a YAML file instead of SQLite:
# Run with state file
./dmt -c config.yaml --state-file state.yaml run
# Resume with state file
./dmt -c config.yaml --state-file state.yaml resume
# All commands support --state-file
./dmt -c config.yaml --state-file state.yaml status
./dmt -c config.yaml --state-file state.yaml historyStarting migration run: a1b2c3d4
Connection pools: MSSQL=12, PostgreSQL=12
Extracting schema...
Found 11 tables
Pagination: 9 keyset, 1 ROW_NUMBER, 1 no PK
Creating target tables (drop and recreate)...
Transferring data...
Transferring 100% |███████████| (106534570/106534570, 697K rows/s)
Transferred 106534570 rows in 2m33s (697008 rows/sec)
Transfer Profile (per table):
------------------------------
Votes query=2.8s (3%), scan=50.6s (55%), write=38.9s (42%), rows=52928720
Comments query=1.6s (0%), scan=87.1s (20%), write=346.5s (80%), rows=24534730
Posts query=3.2s (0%), scan=2183.3s (73%), write=823.5s (27%), rows=17142169
...
Validation Results:
-------------------
Badges OK 8042005 rows
Comments OK 24534730 rows
Posts OK 17142169 rows
Users OK 2465713 rows
Votes OK 52928720 rows
- Extract schema - Reads table structure, PKs, indexes, FKs, and check constraints from source
- Create tables - Generates target DDL with proper type mapping and identity columns
- Transfer data - Uses optimal pagination strategy per table:
- Keyset pagination for single-column integer PKs (fastest)
- ROW_NUMBER pagination for composite/varchar PKs
- Save progress - Checkpoints every 20 chunks by default to SQLite for resume capability
- Finalize - Resets identity sequences, creates primary keys
- Create indexes - Non-PK indexes (if enabled)
- Create foreign keys - FK constraints (if enabled)
- Create check constraints - CHECK constraints (if enabled)
- Validate - Compares row counts and optionally samples random rows
The tool saves progress to enable efficient resume after failures. By default, state is stored in SQLite (~/.dmt/migrate.db). For headless environments, use --state-file for a portable YAML state file.
- Completed tables are skipped entirely on resume
- Verified by comparing row counts between source and target
- Partial outcomes remain resumable by default; outcome and resumability are exposed separately in status/history JSON
migration.allow_partial: trueaccepts a partial as non-resumable; useresume --abandon --abandon-reason <text>to explicitly stop retrying any other recoverable run without deleting its checkpoint history
- Progress saved every 20 chunks by default during transfer
- On resume, continues from the exact last successful chunk
- Partial data from interrupted chunks is cleaned up automatically
- If target table has fewer rows than saved progress, the tool detects data loss
- Automatically clears stale progress and restarts the table transfer
- Prevents resuming with incorrect last_pk values
# Resume shows what's being skipped/continued
./dmt -c config.yaml resume
# Or with state file for Airflow/Kubernetes
./dmt -c config.yaml --state-file state.yaml resume
# Output:
# Resuming run: a1b2c3d4 (started 2025-01-15T10:30:00Z)
# Skipping 5 already-complete tables: [Users, Posts, Comments, Badges, Votes]
# Resuming transfer of 2 tables
# Resuming Orders from chunk (lastPK=1234567, rows=5000000)The tool automatically selects the best pagination strategy per table:
| PK Type | Strategy | Performance |
|---|---|---|
| Single integer (int, bigint) | Keyset (WHERE pk > @last) |
Fastest |
| Composite PK | ROW_NUMBER | Good |
| VARCHAR PK | ROW_NUMBER | Good |
| No PK | Rejected | - |
Tables without primary keys are rejected to ensure data correctness.
| SQL Server | PostgreSQL |
|---|---|
| int | integer |
| bigint | bigint |
| smallint | smallint |
| tinyint | smallint |
| bit | boolean |
| decimal/numeric | numeric |
| float | double precision |
| real | real |
| money | numeric(19,4) |
| char/nchar | char |
| varchar/nvarchar | varchar |
| text/ntext | text |
| date | date |
| time | time |
| datetime/datetime2 | timestamp |
| datetimeoffset | timestamptz |
| uniqueidentifier | uuid |
| varbinary/image | bytea |
| xml | xml |
Identity columns are mapped to GENERATED BY DEFAULT AS IDENTITY with proper sequence reset.
| PostgreSQL | SQL Server |
|---|---|
| integer | int |
| bigint | bigint |
| smallint | smallint |
| boolean | bit |
| numeric/decimal | decimal |
| double precision | float |
| real | real |
| char | char |
| varchar/character varying | nvarchar |
| text | nvarchar(max) |
| date | date |
| time | time |
| timestamp | datetime2 |
| timestamptz | datetimeoffset |
| uuid | uniqueidentifier |
| bytea | varbinary(max) |
| json/jsonb | nvarchar(max) |
Serial/identity columns are mapped to IDENTITY(1,1) with proper seed reset.
For types not in the built-in mappings (custom domains, user-defined types, etc.), enable AI-assisted type mapping to automatically infer the best target type.
- Hardware: WSL2 on Windows, 32GB RAM, 16 cores
- Databases: PostgreSQL 15 and SQL Server 2022 (Docker)
| Direction | Transfer | Overall |
|---|---|---|
| PG → MSSQL | 688K rows/sec | 645K rows/sec |
| PG → PG | 605K rows/sec | 563K rows/sec |
| MSSQL → PG | 302K rows/sec | 248K rows/sec |
| MSSQL → MSSQL | 280K rows/sec | 222K rows/sec |
- Hardware: macOS, Apple Silicon, 36GB RAM
- Databases: SQL Server 2022 and PostgreSQL 17 (Docker, 16GB limit)
| Configuration | Transfer | Overall | Throughput |
|---|---|---|---|
| Smartconfig + runtime tuning | 2m 33s | 3m 08s | 697K rows/sec |
| Runtime tuning only | 5m 14s | 5m 50s | 339K rows/sec |
Smartconfig analyzes source schema, system resources, driver profiles, and completed-run history to set initial parameters, delivering a 2x speedup over runtime-only tuning.
Performance varies based on network latency, table width, data types, and available CPU/memory.
The project includes comprehensive unit tests for CLI parsing, orchestrator logic, and profile encryption.
# Run all tests
make test
# Run tests with short flag (faster, skips slow tests)
make test-short
# Generate coverage report
make test-coverage
# Open coverage.html in browser to view resultsTo ensure tests pass before committing:
# Set up git hooks (one-time setup)
make setup-hooks
# This configures git to use .githooks/pre-commit which:
# - Checks code formatting
# - Runs all tests
# - Blocks commit if any check fails# Build binary
make build
# Build for all platforms
make build-all
# Run all checks (format + tests)
make checkFor integration testing:
# Start local SQL Server and PostgreSQL
make test-dbs-up
# Stop and remove test databases
make test-dbs-downAll tables must have primary keys for migration. Tables without primary keys are automatically skipped with a warning. This is required for:
- Chunked pagination during data transfer
- Change detection in upsert mode
MIT