Fast schema introspection for PostgreSQL, MySQL, SQLite, and MSSQL. Generates SQLAlchemy models, cross-dialect DDL, or migration diffs — with an interactive TUI for reviewing and applying changes. Drop-in replacement for sqlacodegen.
- SQLAlchemy model generation — declarative ORM classes or
Table()metadata, byte-for-byte compatible with sqlacodegen output. - Cross-dialect DDL — introspect one database and emit
CREATE TABLEstatements for a different engine, with automatic type translation (jsonb→JSON,uuid→UNIQUEIDENTIFIER, and so on across all 16 source×target permutations). - Schema diff — compare a source database against a target and emit
ALTER TABLEstatements to converge them. - Interactive TUI — enter two URLs, scroll through the generated diff, and apply it to the target with a confirmation prompt.
- Fast — single static binary; ~10× faster than sqlacodegen on PostgreSQL and ~40× on MSSQL.
cargo install uvgDownload the archive for your platform from the latest release (Linux x86_64/aarch64, macOS aarch64, Windows x86_64), extract, and place uvg on your PATH.
git clone https://github.com/johndauphine/uvg.git
cd uvg
cargo install --path .UVg accepts SQLAlchemy-style URLs for both the source and (optional) target database:
postgresql://user:pass@host/db
mysql://user:pass@host/db
sqlite:///path/to/db.sqlite
mssql://user:pass@host/db
For production use, read Operations and Security before connecting to protected databases. It covers credential redaction, TLS settings, required privileges, logs/errors, generated artifacts, and safe apply workflows.
# Modern declarative classes (default)
uvg postgresql://localhost/mydb -o models.py
# Core Table() metadata objects
uvg --generator tables postgresql://localhost/mydb -o models.py
# One file per table
uvg --split-tables --outfile models/ postgresql://localhost/mydb
# Filter specific tables
uvg --tables users,posts postgresql://localhost/mydbIntrospect a database and emit CREATE TABLE statements for a different engine. Types are translated automatically.
# PostgreSQL schema -> MySQL DDL
uvg postgresql://localhost/mydb --generator ddl --target-dialect mysql -o schema.sql
# MSSQL schema -> SQLite DDL, one file per table
uvg mssql://localhost/mydb --generator ddl --target-dialect sqlite --split-tables -o ddl/Compare a source database against a live target and emit ALTER TABLE statements to converge them.
uvg postgresql://source/db mysql://target/db --generator ddl -o migration.sqlThe target dialect is inferred from the target URL scheme. Same-dialect migrations converge cleanly — running the diff again after applying shows zero changes.
For Alembic-style workflows, UVg can write timestamped revision files and track the target database's current revision in a uvg_version table.
# Scaffold migrations/ (baseline + meta.yaml) and a profiles.yaml stub
# at ~/.config/uvg/profiles.yaml that `--profile` reads
uvg init
# Write migrations/<revision>_add-users-email.sql
uvg revision postgresql://source/db postgresql://target/db \
--message "add users.email"
# Apply pending revisions to the target and update uvg_version
uvg upgrade postgresql://target/db
# Roll back one revision, or back to a named revision/base
uvg downgrade postgresql://target/db
uvg downgrade postgresql://target/db base
# Collapse multiple migration heads into one merge revision
uvg merge --message "merge active migration branches"
# Adopt an already-current target without running migration SQL
uvg stamp postgresql://target/db 20260519_141500 --yes
# Inspect the target's current revision
uvg current postgresql://target/db
# Show the local revision chain
uvg history
uvg history postgresql://target/dbRevision files use a simple SQL format:
-- uvg revision: 20260519_141500
-- parent: 20260518_120000
-- description: add users.email
-- UP
ALTER TABLE "users" ADD COLUMN "email" VARCHAR(255);
-- DOWN
ALTER TABLE "users" DROP COLUMN "email";Optional hook sections run around the main change: -- PRE, -- UP, -- POST, then uvg_version is bumped. Downgrades run -- POST DOWN, -- DOWN, -- PRE DOWN before moving the recorded revision back. Generated irreversible down sections are marked with -- IRREVERSIBLE: and are refused by uvg downgrade until the user replaces them with real rollback SQL.
Branched histories are supported through explicit merge revisions. When uvg history shows multiple heads, run uvg merge --message <name> to write an empty multi-parent merge revision that restores a single head. Automatic downgrade through merge revisions is refused because uvg_version records one current revision; resolve that case manually and use uvg stamp.
uvg upgrade and uvg downgrade parse-check migration SQL before applying it when the target dialect has a reliable parse-only mode. PostgreSQL uses a savepoint per statement inside one rolled-back transaction, so it catches syntax and catalog errors. SQL Server, MySQL, and SQLite do not expose a safe parse-only DDL mode for UVG's apply path, so UVG prints a one-line skip note and continues. Pass --no-parse-check before the subcommand to disable this phase explicitly.
Apply atomicity is per-dialect. On PostgreSQL, each apply batch and each migration section runs inside a single transaction: a failure rolls the batch back and leaves the target unchanged, and statements that cannot run inside a transaction block (CREATE INDEX CONCURRENTLY, VACUUM, ...) automatically fall back to statement-by-statement apply. On SQL Server, MySQL, and SQLite, DDL implicitly commits, so migration DDL is applied statement-by-statement and earlier statements in a failed migration may already be applied. Either way, on failure UVg stops at the first failed statement and prints the migration revision, section, statement number, dialect error, and failed SQL without printing the target URL — and says whether the section was rolled back. uvg_version is advanced only after all -- PRE, -- UP, and -- POST SQL succeeds; downgrades move or clear uvg_version only after all down SQL succeeds. See Operations and Security for the full guarantees.
Recovery is intentionally explicit: fix the target manually if needed, rerun an idempotent migration once the target is safe, or use uvg stamp <target-url> <revision> --yes only after verifying the schema already matches that revision. If a downgrade to base applied but clearing uvg_version failed, verify the target and clear uvg_version manually.
Statement retry is bounded and only applies to transient execution errors such as deadlocks, lock waits, and brief connection drops. Logical errors such as syntax errors, missing objects, and constraint failures are not retried. Generated rollback SQL marks unknown or destructive reversals with -- IRREVERSIBLE:; uvg downgrade refuses those sections until they are replaced with real rollback SQL.
The default DDL diff path writes one blob to stdout (or --outfile). For migrations you commit to git, --out-dir splits the output one file per table so git log -- migrations/users/ is the history of the users table.
uvg postgresql://source/db postgresql://target/db \
--generator ddl \
--out-dir migrations/ \
--name initialAfter a first run against a blank target:
migrations/
├── users/20260513T193000Z__initial.sql
├── posts/20260513T193000Z__initial.sql
└── _runs/20260513T193000Z__initial.json # manifest of this run
Each .sql file starts with a provenance header so a reviewer can see what produced it without leaving the file:
-- Generated by uvg 1.5.0 on 2026-05-13T19:30:00Z (UTC)
-- Run: 20260513T193000Z__initial
-- Table: public.users
-- Source: postgres -> Target: postgresRe-run with no schema changes: nothing happens. uvg prints uvg: no schema changes to stderr, writes zero files, and exits 0. The directory is byte-identical — no new git diff.
Re-run after you add a column to users:
migrations/
├── users/20260513T193000Z__initial.sql
├── users/20260514T084500Z__add-email.sql ← only new file
├── posts/20260513T193000Z__initial.sql
├── _runs/20260513T193000Z__initial.json
└── _runs/20260514T084500Z__add-email.json
Only the table that actually changed gets a new file. Unmodified tables stay untouched.
Notes:
- Non-table-scoped DDL (enum
CREATE TYPE,CREATE SCHEMA, etc.) lands in_schema/. Apply order is_schema/first, then per-table files. - Subdirectory naming:
<table>for default-schema tables,<schema>__<table>for non-default schemas (e.g.billing__orders/). --name <SLUG>sets the suffix used in filenames; defaults to<source>_to_<target>(e.g.postgres_to_mysql).--out-dirrequires a target URL (it diffs source against target). For full DDL dumps, use--outfileor--split-tables.- If
--outfileand--out-dirare both set,--outfilewins.
Launch the TUI to enter source and target URLs, review the generated diff, and apply it to the target with a confirmation prompt.
uvg -i # prompts for URLs
uvg -i postgresql://source/db postgresql://target/dbWhen the diff spans multiple tables, the TUI shows a left tree pane (one entry per table, plus _schema for non-table-scoped DDL) and a right detail pane with the SQL for the selected node. Each node has a checkbox — toggle with Space, toggle all with A, then press a to apply only the checked nodes (_schema/ first, then tables in topological order).
| Flag | Description |
|---|---|
--generator <TYPE> |
declarative (default), tables, or ddl |
--target-dialect <DIALECT> |
Target SQL dialect for DDL: postgres, mysql, sqlite, mssql |
--split-tables |
Output one file per table (works with all generators) |
--tables <LIST> |
Comma-delimited table names to include |
--schemas <LIST> |
Schemas to introspect (default: public for PG, dbo for MSSQL, database name for MySQL) |
--noviews |
Skip views |
--options <LIST> |
noindexes, noconstraints, nocomments, nobidi, nofknames, noidsuffix, nosyntheticenums, nonativeenums, keep_dialect_types |
--outfile <PATH> |
Output file or directory (default: stdout). Wins over --out-dir if both are set |
--out-dir <DIR> |
Per-table migration layout for --generator ddl with a target URL. No-op runs write nothing — see above |
--name <SLUG> |
Filename suffix used inside --out-dir (default: <source>_to_<target>) |
--interactive, -i |
Launch interactive TUI for DDL diff and apply |
--trust-cert |
Trust the server certificate (MSSQL only; see Operations and Security) |
from typing import Optional
import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Users(Base):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(255), unique=True)
bio: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[Optional[datetime.datetime]] = mapped_column(
DateTime(timezone=True), server_default=text('now()')
)from sqlalchemy import Column, Integer, MetaData, String, Table, Text
metadata = MetaData()
t_users = Table(
'users', metadata,
Column('id', Integer, primary_key=True),
Column('name', String(100), nullable=False),
Column('email', Text),
schema='public'
)Scalars: bool, int2, int4, int8, float4, float8, numeric, text, varchar, char, bytea, date, time, timetz, timestamp, timestamptz, interval
Dialect types: uuid, json, jsonb, inet, cidr
Arrays: _int4, _text, and other array types via the ARRAY() wrapper
URL schemes: postgresql://, postgresql+psycopg2://
Scalars: bit, tinyint, smallint, int, bigint, real, float, decimal, numeric, money, smallmoney
Strings: char, varchar, nchar, nvarchar, text, ntext (with collation)
Date/time: date, time, datetime, datetime2, smalldatetime, datetimeoffset
Binary: binary, varbinary, image
Dialect types: uniqueidentifier
URL schemes: mssql://, mssql+pytds://, mssql+pyodbc://, mssql+pymssql://
Scalars: tinyint, smallint, mediumint, int, bigint (with unsigned variants), float, double, decimal
Strings: char, varchar, text, tinytext, mediumtext, longtext
Date/time: date, time, datetime, timestamp, year
Binary: binary, varbinary, blob, tinyblob, mediumblob, longblob
Special: json, enum, set, bit, boolean
Note: tinyint(1) is automatically mapped to Boolean.
URL schemes: mysql://, mysql+pymysql://, mysql+mysqldb://, mysql+aiomysql://, mysql+asyncmy://, mariadb://, mariadb+pymysql://
SQLite uses type affinity. Recognized types: integer, smallint, bigint, real, float, double, numeric, decimal, text, varchar, char, blob, date, datetime, timestamp, time, boolean, json
Unknown types are mapped using SQLite affinity rules (contains "INT" -> Integer, contains "TEXT"/"CHAR" -> Text, etc.).
AUTOINCREMENT is detected from the CREATE TABLE SQL. CHECK constraints are parsed for synthetic enum generation.
URL schemes: sqlite:///relative/path, sqlite:////absolute/path, sqlite:///:memory:
Benchmarked against sqlacodegen 3.2.0 on the StackOverflow 2010 database (9 tables) using hyperfine:
| Command | Mean | Min | Max |
|---|---|---|---|
| sqlacodegen (PostgreSQL) | 1.140s | 1.099s | 1.167s |
| uvg (PostgreSQL) | 113.4ms | 108.1ms | 130.5ms |
| sqlacodegen (MSSQL) | 1.187s | 1.134s | 1.231s |
| uvg (MSSQL) | 29.9ms | 28.1ms | 32.1ms |
10x faster on PostgreSQL, 40x faster on MSSQL.
cargo testBefore opening a PR, run the same quality gates CI enforces:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-featuresFor a local advisory view of Rust module size and test layout:
./scripts/module_size_report.pyThis report is not a CI gate by default. It distinguishes approximate
production lines from inline #[cfg(test)] lines and dedicated test files; see
docs/module-size-report.md for the guideline.
CI also runs dependency, advisory, license, and source checks with
cargo-deny. To run the same gate locally:
cargo install --locked cargo-deny
cargo deny --all-features --locked check advisories licenses bans sourcesThe live integration smoke tests are split into feature-focused test crates. PostgreSQL and MySQL introspection require a live database; SQLite runs in-memory:
# PostgreSQL introspection
DATABASE_URL=postgresql://user:pass@localhost/testdb cargo test --test introspect test_introspect_live_pg -- --ignored
# PostgreSQL versioned migration workflow on a disposable database
UVG_DISPOSABLE_PG_URL=postgresql://user:pass@localhost/testdb cargo test --test migrations test_versioned_migration_live_postgres_workflow_cli -- --ignored
# MySQL introspection
MYSQL_URL=mysql://user:pass@localhost/testdb cargo test --test introspect test_introspect_live_mysql -- --ignored
# SQLite (runs automatically, no server needed)
cargo test --test introspect test_introspect_sqlite_in_memoryMicrosoft SQL Server end-to-end coverage is provided by the cross-dialect CRM matrix below.
testdata/crm/ ships a 14-table CRM schema in three native dialects
(MSSQL, PostgreSQL, MySQL) plus a runner that drives uvg through every
(source, target) permutation. As of v1.5.0 all 9 pairs apply cleanly
in ~7 seconds total. See testdata/crm/README.md
for setup and expected counts.
cargo build --release
./testdata/crm/run_matrix.shBefore calling a release stable, maintainers run UVG against authorized
real-world schemas and record the outcome in
docs/beta-validation.md. The helper below creates
a private validation bundle with generated models, DDL, optional diff/apply
logs, and optional versioned migration logs. It records the Git commit, UVg
version, and available database versions, syntax-checks generated Python, and
imports it when SQLAlchemy is installed:
cargo build --release
scripts/beta_validate_schema.sh --helpFor production-sized schema-evolution coverage, maintainers also run the
StackOverflow2010 drift matrix in
testdata/stackoverflow-drift/. The optional
GitHub Actions workflow (run manually via workflow_dispatch) restores the SQL
Server source database from STACKOVERFLOW2010_BAK_URL, applies committed drift
packs, and requires SQL Server, PostgreSQL, MySQL, and SQLite targets to converge
after every applicable pack.
Maintainers cut releases using docs/release.md. User-facing
changes are tracked in CHANGELOG.md, and GitHub release
archives include SHA-256 checksum files generated by the release workflow.
UVg is a reimplementation of sqlacodegen by Alex Grönholm, licensed under MIT. See NOTICE for full attribution.
Dual-licensed under either of:
- MIT License (LICENSE-MIT)
- Apache License, Version 2.0 (LICENSE-APACHE)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.