{"id":50318665,"url":"https://github.com/brian-pond/dbconform","last_synced_at":"2026-05-29T02:01:26.430Z","repository":{"id":341931834,"uuid":"1171856642","full_name":"brian-pond/dbconform","owner":"brian-pond","description":"Finds and repairs SQL database drift by comparing against data models.","archived":false,"fork":false,"pushed_at":"2026-05-01T15:54:03.000Z","size":1000,"stargazers_count":4,"open_issues_count":1,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-01T17:31:08.676Z","etag":null,"topics":["alembic","database","ddl","drift-detection","orm","postgresql","python","schema-migration","sqlalchemy","sqlite","sqlmodel"],"latest_commit_sha":null,"homepage":"https://pypi.org/project/dbconform","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/brian-pond.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-03T17:24:26.000Z","updated_at":"2026-05-01T15:54:07.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/brian-pond/dbconform","commit_stats":null,"previous_names":["brian-pond/dbconform"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/brian-pond/dbconform","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brian-pond%2Fdbconform","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brian-pond%2Fdbconform/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brian-pond%2Fdbconform/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brian-pond%2Fdbconform/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/brian-pond","download_url":"https://codeload.github.com/brian-pond/dbconform/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brian-pond%2Fdbconform/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33633468,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["alembic","database","ddl","drift-detection","orm","postgresql","python","schema-migration","sqlalchemy","sqlite","sqlmodel"],"created_at":"2026-05-29T02:01:25.692Z","updated_at":"2026-05-29T02:01:26.418Z","avatar_url":"https://github.com/brian-pond.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# dbconform\n\n**Your database schema has drifted. `dbconform` fixes it.**\n\nOver time, databases can diverge from your SQLAlchemy models — columns get added manually, constraints go missing, a hotfix gets applied directly to the DB and never captured in code. This is *database drift*, and it's a real-world, compounding problem.\n\nSQLAlchemy's `create_all()` only creates new tables. Alembic works well for disciplined linear migrations, but it has no answer for drift: when your database diverges from your migration history, you're on your own.\n\n`dbconform` inspects your live database, compares it against your SQLAlchemy (or SQLModel) models, and either tells you exactly what's wrong — or *fixes* it.\n\n```python\nfrom dbconform import DbConform\nfrom my_app.my_alchemy_schemas import Product, Cart # your own models\n\nconform = DbConform(credentials={\"url\": \"sqlite:///./mydb.sqlite\"})\nresult = conform.apply_changes([Product, Cart])\n\nprint(f\"Applied {len(result.steps)} change(s). Target database schema is conformant.\")\n```\n\nThat's it. No migration files, history table, CLI, or additional infrastructure.\n\n✅ \u0026nbsp;\u0026nbsp;Supports both sync/async Python\\\n✅ \u0026nbsp;\u0026nbsp;SQLite\\\n✅ \u0026nbsp;\u0026nbsp;PostgreSQL\\\n🏗️ \u0026nbsp;\u0026nbsp;MariaDB (in-scope for future development)\n\n---\n\n## Why not Alembic?\n\nAlembic is excellent when you start clean -and- stay disciplined. But that's just not always the situation we find ourselves in.  So I wanted a tool that just fixes the problems, and lets me get on with my work:\n\n| Capability | SQLAlchemy `create_all` | Alembic | Atlas | **dbconform** |\n|---|:---:|:---:|:---:|:---:|\n| Create new tables | ✅ | ✅ | ✅ | ✅ |\n| Alter existing tables | ❌ | ✅ | ✅ | ✅ |\n| Can fix schema drift | ❌ | ❌ | ✅ | ✅ |\n| Works without migration history | ✅ | ❌ | ❌ | ✅ |\n| Pure Python, `pip install` | ✅ | ✅ | ❌ | ✅ |\n| SQLite rebuild capabilities | ❌ | ❌ | ❌ | ✅ |\n| Safe defaults (no accidental drops) | ✅ | ⚠️ | ⚠️ | ✅ |\n| In-process, programmatic | ✅ | ✅ | ❌ | ✅ |\n\n\u003e **Atlas** is a powerful schema platform — excellent for CI/CD pipelines and cloud drift monitoring. It's a Go CLI tool with its own infrastructure. `dbconform` is a Python library you call from application code.\n\n---\n\n## When to use dbconform\n\n- You inherited a database and models, but the migrations have gone sideways.\n- Your databases in development and production have structurally diverged.\n- You want to programmatically enforce schema conformance at application startup (*one of my personal favorites*)\n- You don't want to manage migration history at all, with something like Alembic.\n- Someone ran a hotfix directly on the database and now you need to reconcile.\n\n---\n\n## Installation\n\n```bash\npip install dbconform\n```\n\nOptional extras:\n\n```bash\npip install dbconform[postgres]        # PostgreSQL support (psycopg)\npip install dbconform[async]           # Async drivers (aiosqlite, asyncpg)\npip install dbconform[async,postgres]  # Both\n```\n\n**Requirements:** Python 3.11+\n\n---\n\n## Quick Start\n\n### Define your models (SQLAlchemy or SQLModel)\n\n```python\nfrom sqlalchemy import Column, Float, ForeignKey, Integer, String\nfrom sqlalchemy.orm import DeclarativeBase\n\nclass Base(DeclarativeBase):\n    pass\n\nclass Product(Base):\n    __tablename__ = \"product\"\n    id = Column(Integer, primary_key=True, autoincrement=True)\n    name = Column(String(255), nullable=False)\n    price = Column(Float, nullable=False)\n\nclass Cart(Base):\n    __tablename__ = \"cart\"\n    id = Column(Integer, primary_key=True, autoincrement=True)\n    product_id = Column(Integer, ForeignKey(\"product.id\"), nullable=False)\n    quantity = Column(Integer, nullable=False)\n```\n\n### Compare (dry run)\n\n`compare()` builds a **`ConformPlan`** and does not change the database.\n\n```python\nfrom dbconform import DbConform, ConformError\n\nconform = DbConform(credentials={\"url\": \"sqlite:///./mydb.sqlite\"})\nresult = conform.compare([Product, Cart])  # ConformPlan | ConformError\n\nif isinstance(result, ConformError):\n    print(\"Compare failed:\", result.messages)\nelif not result.steps:\n    print(\"Database is up to date.\")\nelse:\n    result.print_summary()\n```\n\n**Ways to inspect the plan:**\n\n- **`print_summary()`** / **`summary()`** — Human-readable counts and descriptions: planned steps, **extra tables** (present in the DB but not in your models), and **skipped steps** (drift left behind because of safety flags or backend limits).\n- **`sql()`** — One multi-line string of DDL (plus comments where the plan includes SQLite table rebuilds). **`statements()`** — List of non-empty SQL strings from individual steps (handy for drivers that execute one statement at a time).\n- **`steps`**, **`extra_tables`**, **`skipped_steps`** — Use these attributes directly if you need structured data for your own reporting or tooling.\n\n### Apply changes\n\n```python\nresult = conform.apply_changes([Product, Cart])  # ConformPlan | ConformError\n\nif isinstance(result, ConformError):\n    print(\"Apply failed:\", result.messages)\nelse:\n    print(f\"Applied {len(result.steps)} change(s). Schema is conformant.\")\n```\n\nBy default all steps run in a **single transaction** — any failure rolls back everything. Set `commit_per_step=True` to commit after each step so prior steps persist if a later one fails.\n\nEach applied step is also emitted as a **JSON-line log** to stdout. Pass `emit_log=False` to suppress it, or `log_file=\"path/to/conform.log\"` to append to a file (no credentials are ever included in logs).\n\n---\n\n## Connections\n\n### Connection options\n\nPass `credentials` and dbconform manages the connection lifecycle, or pass your own `connection` and manage it yourself.\n\n```python\n# SQLite — credentials\nconform = DbConform(credentials={\"url\": \"sqlite:///./mydb.sqlite\"})\n\n# PostgreSQL — credentials (target_schema is required)\nconform = DbConform(\n    credentials={\"url\": \"postgresql+psycopg://user:pass@host/db\"},\n    target_schema=\"public\"\n)\n\n# Or bring your own connection (any supported backend)\nfrom sqlalchemy import create_engine\n\nengine = create_engine(\"sqlite:///./mydb.sqlite\")\nwith engine.connect() as conn:\n    conform = DbConform(connection=conn)\n    result = conform.compare([Product, Cart])\nengine.dispose()\n```\n\n### Async\n\n```python\nimport asyncio\nfrom sqlalchemy.ext.asyncio import create_async_engine\nfrom dbconform import AsyncDbConform, ConformError\n\nasync def main():\n    engine = create_async_engine(\"sqlite+aiosqlite:///./mydb.sqlite\")\n    async with engine.connect() as conn:\n        conform = AsyncDbConform(async_connection=conn)\n        result = await conform.apply_changes([Product, Cart])\n    await engine.dispose()\n\nasyncio.run(main())\n```\n\n---\n\n## What dbconform conforms\n\nBy default (add/alter only — no drops unless opted in):\n\n| Element | What dbconform does |\n|---|---|\n| **Tables** | Create missing tables |\n| **Columns** | Add missing; alter type, nullability, and default |\n| **Primary keys** | Add missing |\n| **Unique constraints** | Add/remove |\n| **Foreign keys** | Add/remove |\n| **Check constraints** | Add/remove |\n| **Indexes** | Create/drop |\n| **Comments** | Sync table and column comments (where the backend supports them) |\n\nSteps are emitted in **dependency order** — e.g., a table is created before any foreign key that references it.\n\n**Column defaults:** Python scalar defaults on SQLAlchemy/SQLModel columns (e.g. `default=date(1970, 1, 1)` on a `DATE` column) are emitted as properly quoted literals so the database interprets them correctly.\n\n**NOT NULL on a column with existing NULLs:** If the model defines a column default, dbconform backfills NULLs automatically before adding the `NOT NULL` constraint. If there is no default, it returns a `ConformError` — backfill the column manually first, then retry.\n\n**SQLite constraint limits:** SQLite cannot add CHECK, UNIQUE, or FOREIGN KEY constraints via `ALTER TABLE`. By default (`allow_sqlite_table_rebuild=True`), dbconform rebuilds the table (create new → copy data → drop old → rename), preserving all data and indexes. Set `allow_sqlite_table_rebuild=False` to skip rebuilds; skipped steps appear in `plan.skipped_steps`.\n\n**Future (not yet in scope):** sequences, triggers, enums.\n\n---\n\n## Safe by Default\n\n`dbconform` will not drop tables or columns unless you explicitly opt in. The defaults are designed to be safe in production.\n\n| Flag | Default | What it controls |\n|---|:---:|---|\n| `allow_drop_extra_tables` | `False` | DROP TABLE for tables not in your models |\n| `allow_drop_extra_columns` | `False` | DROP COLUMN for columns not in your models |\n| `allow_drop_extra_constraints` | `True` | DROP CONSTRAINT / DROP INDEX for removed constraints |\n| `allow_shrink_column` | `False` | ALTER COLUMN that reduces size (may truncate data) |\n| `allow_sqlite_table_rebuild` | `True` | SQLite table rebuild for CHECK/UNIQUE/FK changes |\n| `report_extra_tables` | `True` | Populate `plan.extra_tables` with tables in DB but not in your models |\n\n`apply_changes()` additional flags:\n\n| Flag | Default | What it controls |\n|---|:---:|---|\n| `commit_per_step` | `False` | Commit after each step (partial progress persists on failure) |\n| `emit_log` | `True` | JSON-line log to stdout for each applied step |\n| `log_file` | `None` | Path to also append logs to a file |\n\nAll flags are passed as keyword arguments:\n\n```python\nresult = conform.apply_changes(\n    [Product, Cart],\n    allow_drop_extra_columns=True,\n    allow_shrink_column=True\n)\n```\n\n---\n\n## Errors\n\n`ConformError` is **returned as a value**, not raised. Check for it with `isinstance`:\n\n```python\nif isinstance(result, ConformError):\n    print(result.messages)        # list[str] — what went wrong\n    print(result.target_objects)  # list of (object_type, name) e.g. [(\"table\", \"public.foo\")]\n```\n\n`ConformError` also inherits from `Exception`, so `raise X from result` and `except ConformError` work if you prefer the exception pattern.\n\n---\n\n## Contributing\n\nIssues and pull requests are welcome. For local development:\n\n```bash\npython3 -m venv .venv\nsource .venv/bin/activate\npip install -e \".[dev,async,postgres]\"\n```\n\nRunning tests (Docker or Podman required for PostgreSQL tests):\n\n```bash\ndbconform test run\n```\n\nTo see the installed `dbconform` version:\n\n```bash\ndbconform version\n```\n\nSee `tests/TESTS_README.md` for the full test organization.\n\n---\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbrian-pond%2Fdbconform","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbrian-pond%2Fdbconform","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbrian-pond%2Fdbconform/lists"}