Open-source database DevOps

Database migrations without surprises.

Ptah compares the schema you want with a live database and either writes versioned migrations or applies an approved plan. It also migrates persistent inference state, such as re-embedding a pgvector table. PostgreSQL, MySQL, MariaDB, SQLite and more. Open source, MIT, no toolchain to install.

$ curl -fsSL https://ptah.run/install.sh | sh

v0.13.0 · pre-GA · MITWindows: irm https://ptah.run/install.ps1 | iex

Change
sh · ptah-quick-start 49 / 49
# Write the database's own schema into a file.$ ptah db read --db-url sqlite://app.db | tee schema.sqlCREATE TABLE "users" (  "id" INTEGER PRIMARY KEY,  "email" TEXT NOT NULL); # Drift compares that file with the database it came from.$ ptah schema drift --schema-file schema.sql \    --db-url sqlite://app.dbNo schema drift detected. # Now ask for a column, by rewriting the schema you want.$ cat > schema.sql <<'SQL'CREATE TABLE "users" (  "id" INTEGER PRIMARY KEY,  "email" TEXT NOT NULL,  "created_at" TEXT);SQL # The same check now has something to report.$ ptah schema drift --schema-file schema.sql \    --db-url sqlite://app.dbSchema drift detected (highest severity: warning).Failure threshold: all. Failing: true.Database: sqlite://app.db Findings:- columns_added: 1 (warning) # Ask what it would take to close it. A dry run executes nothing.$ ptah schema apply --schema-file schema.sql \    --db-url sqlite://app.db --dry-runPlanned schema changes:ALTER TABLE "users" ADD COLUMN "created_at" TEXT; # Run the reviewed plan. --auto-approve suits a disposable file.$ ptah schema apply --schema-file schema.sql \    --db-url sqlite://app.db --auto-approvePlanned schema changes:ALTER TABLE "users" ADD COLUMN "created_at" TEXT;Auto-approval enabled; applying schema changes.Schema apply completed successfully. # And the same check, a third time.$ ptah schema drift --schema-file schema.sql \    --db-url sqlite://app.dbNo schema drift detected.
schema.sql matches the database+1 line−1 line+7 lines
CREATE TABLE users (  id INTEGER PRIMARY KEY,  email TEXT NOT NULL);
CREATE TABLE users (  id INTEGER PRIMARY KEY,  email TEXT NOT NULL,+  created_at TEXT);
CREATE TABLE users (  id INTEGER PRIMARY KEY−  email TEXT NOT NULL);
CREATE TABLE users (  id INTEGER PRIMARY KEY,  email TEXT NOT NULL);+ +CREATE TABLE sessions (+  id INTEGER PRIMARY KEY,+  user_id INTEGER NOT NULL,+  token TEXT NOT NULL,+  FOREIGN KEY (user_id) REFERENCES users(id)+);
sh
--db-url
$ ptah schema drift --schema-file schema.sql \    --db-url sqlite://app.dbNo schema drift detected.exit 0 $ ptah schema apply --schema-file schema.sql \    --db-url sqlite://app.db --dry-runSchema is synced, no changes to be made.exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url sqlite://app.dbSchema drift detected (highest severity: warning).Failure threshold: all. Failing: true.Database: sqlite://app.db Findings:- columns_added: 1 (warning)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url sqlite://app.db --dry-runPlanned schema changes:ALTER TABLE "users" ADD COLUMN "created_at" TEXT;exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url sqlite://app.dbSchema drift detected (highest severity: destructive).Failure threshold: all. Failing: true.Database: sqlite://app.db Findings:- columns_removed: 1 (destructive)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url sqlite://app.db --dry-runPlanned schema changes:-- Disable foreign-key enforcement for the table rebuild belowPRAGMA foreign_keys = off;-- SQLite table rebuild for changes ALTER TABLE cannot express on usersCREATE TABLE "__ptah_rebuild_users" (  "id" INTEGER PRIMARY KEY);INSERT INTO "__ptah_rebuild_users" ("id") SELECT "id" FROM "users";DROP TABLE "users";ALTER TABLE "__ptah_rebuild_users" RENAME TO "users";-- Restore foreign-key enforcement after the table rebuildPRAGMA foreign_keys = on;exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url sqlite://app.dbSchema drift detected (highest severity: warning).Failure threshold: all. Failing: true.Database: sqlite://app.db Findings:- constraints_added: 1 (warning)- tables_added: 1 (safe)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url sqlite://app.db --dry-runPlanned schema changes:CREATE TABLE "sessions" (  "id" INTEGER PRIMARY KEY,  "user_id" INTEGER NOT NULL,  "token" TEXT NOT NULL,  CONSTRAINT "fk_sessions_user_id" FOREIGN KEY ("user_id") REFERENCES "users" ("id"));exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url postgres://app@db/appNo schema drift detected.exit 0 $ ptah schema apply --schema-file schema.sql \    --db-url postgres://app@db/app --dry-runSchema is synced, no changes to be made.exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url postgres://app@db/appSchema drift detected (highest severity: warning).Failure threshold: all. Failing: true.Database: postgres://app@db/app Findings:- columns_added: 1 (warning)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url postgres://app@db/app --dry-runPlanned schema changes:-- Add/modify columns for table: users ---- ALTER statements: --ALTER TABLE "users" ADD COLUMN "created_at" TEXT;exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url postgres://app@db/appSchema drift detected (highest severity: destructive).Failure threshold: all. Failing: true.Database: postgres://app@db/app Findings:- columns_removed: 1 (destructive)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url postgres://app@db/app --dry-runPlanned schema changes:-- Remove columns from table: users ---- ALTER statements: --ALTER TABLE "users" DROP COLUMN "email" CASCADE;-- WARNING: Dropping column users.email with CASCADE - This will delete data and dependent objects! --;exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url postgres://app@db/appSchema drift detected (highest severity: warning).Failure threshold: all. Failing: true.Database: postgres://app@db/app Findings:- constraints_added: 1 (warning)- tables_added: 1 (safe)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url postgres://app@db/app --dry-runPlanned schema changes:-- POSTGRES TABLE: sessions --CREATE TABLE "sessions" (  "id" INTEGER PRIMARY KEY NOT NULL,  "user_id" INTEGER NOT NULL,  "token" TEXT NOT NULL);-- ALTER statements: --ALTER TABLE "sessions" ADD CONSTRAINT "fk_sessions_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id");exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url mysql://app@db/appNo schema drift detected.exit 0 $ ptah schema apply --schema-file schema.sql \    --db-url mysql://app@db/app --dry-runSchema is synced, no changes to be made.exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url mysql://app@db/appSchema drift detected (highest severity: warning).Failure threshold: all. Failing: true.Database: mysql://app@db/app Findings:- columns_added: 1 (warning)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url mysql://app@db/app --dry-runPlanned schema changes:-- Modify table: users ---- ALTER statements: --ALTER TABLE `users` ADD COLUMN `created_at` TEXT;exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url mysql://app@db/appSchema drift detected (highest severity: destructive).Failure threshold: all. Failing: true.Database: mysql://app@db/app Findings:- columns_removed: 1 (destructive)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url mysql://app@db/app --dry-runPlanned schema changes:-- Modify table: users ---- ALTER statements: --ALTER TABLE `users` DROP COLUMN `email`;-- WARNING: Dropping column users.email - This will delete data! --;exit 0
$ ptah schema drift --schema-file schema.sql \    --db-url mysql://app@db/appSchema drift detected (highest severity: warning).Failure threshold: all. Failing: true.Database: mysql://app@db/app Findings:- constraints_added: 1 (warning)- tables_added: 1 (safe)exit 1 $ ptah schema apply --schema-file schema.sql \    --db-url mysql://app@db/app --dry-runPlanned schema changes:-- MYSQL TABLE: sessions --CREATE TABLE `sessions` (  `id` INTEGER PRIMARY KEY,  `user_id` INTEGER NOT NULL,  `token` TEXT NOT NULL);-- ALTER statements: --ALTER TABLE `sessions` ADD CONSTRAINT `fk_sessions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`);exit 0
You write the schema you want. Ptah says what that will cost before anything runs, and drift says whether the database agrees afterwards. More recorded workflows →
How it works

One comparison. Every workflow reads it.

Whatever you write the schema in, Ptah parses it into one desired schema and diffs it against the database. Drift, plans, lint and apply are four readings of that one diff — and every check exits 0, 1 or 2, so CI never mistakes a broken connection for a clean schema.

SOURCES · ANY MIXschema.sqlschema.yamlschema.hclschema.dbmlmodels/*.goorm loader · stdoutdesired schemaLIVE DATABASEsupported databases →compareone diffWHAT COMES OUTschema driftthe proof012schema plan · --dry-runthe review surfacemigrations lintthe gateschema apply · migrations upafter approval
  1. schema.sqlschema.yamlschema.hclschema.dbmlmodels/*.goorm loader · stdout
  2. desired schemalive database
  3. compare
  4. schema driftthe proof 0 1 2
  5. schema plan · --dry-runthe review surface
  6. migrations lintthe gate
  7. schema apply · migrations upafter approval → live database

Three things it does in a typical week.

Real commands, real output. Exit codes are stable across all of them: 0 success, 1 a negative check result, 2 a usage or connection failure.

Keep unsafe and destructive migrations out of main.

$ in CI.github/workflows/ptah.yml
- uses: stokaro/ptah-action@v1
  with:
    dir: ./internal/models
    db-url: ${{ secrets.PTAH_DATABASE_URL }}
    dialect: postgres
    migration-dir: ./migrations

$ ptah migrations lint --dir ./migrations \
    --dialect postgres
migrations/1790114595_drop_bio.up.sql:1 [warning] BC104: dropping a column retires a name application versions already deployed against the old schema still select and insert, so each of them starts failing the moment this migration commits, whether or not the column held any rows; deploy readers that no longer use the column first, then drop it in a later release (dropped column breaks deployed code)
migrations/1790114595_drop_bio.up.sql:1 [error] DS102: DROP COLUMN permanently deletes the column's data; deploy readers that no longer use the column first, then drop it in a later release (column dropped)

2 finding(s).
Checks · pull requeststokaro/ptah-action@v1
Ptah destructive-change verdictfailure
Ptah found destructive migration statements
Safety verdict: destructive.
Destructive changes: yes.
Comment posted by ptah-action
## Ptah migration plan

-- Remove columns from table: users --
-- ALTER statements: --
ALTER TABLE "users" DROP COLUMN "bio" CASCADE;

The action plans the migration against the target database, posts the plan as a comment and writes one check run that fails when a statement would destroy data. ptah migrations lint exits 1 on a finding and 2 when it could not run, so a broken connection never looks like a clean directory.

Keep unreviewed migrations away from the database.

shseal
$ ptah migrations hash --dir ./migrations
Wrote ./migrations/ptah.sum
4 migration file(s) hashed
$ cat ./migrations/ptah.sum
h1:wmq3WUjBVQT+Ll4SPPan7wJGghKMtqi2yDc2Tc4AKnM=
1790114505_create_users.down.sql h1:3hAVcH5B1mghhsNEDFiyIlGOdernvs+6dDRkaO79/F0=
1790114505_create_users.up.sql h1:WJmUNv9ET7Qaypt2hpFNHHrqSQ5XGu0Yi0gMV0gLEMo=
1790114510_add_created_at.down.sql h1:3E0vyUoTX+11C374nPdXruUtJycoXTRoGvl8XsHLLIk=
1790114510_add_created_at.up.sql h1:9wID/+fArtJnyBz/JvM/NDDd1Ovm5SvkaJYcygIXLc4=
shlater, after an applied file was edited
$ ptah migrations up \
    --db-url sqlite://app.db \
    --migrations-dir ./migrations
error: migration sum verification failed:
migration directory does not match ptah.sum:
  changed: 1790114505_create_users.up.sql
$ echo $?
2

ptah.sum records a checksum per file. up, validate and lint check it before they do anything, and status does with --verify-sum. What CI reviewed is what production runs.

Switch embedding models without a search outage.

g1 · ptah-docs-stub · the generation queries readrun g1: cut_over, running
g2 · ptah-docs-stub-v2 · the new modelerror: not found: run g2
g1 · ptah-docs-stub · the generation queries readrun g1: cut_over, running
g2 · ptah-docs-stub-v2 · the new modelrun g2: boundary_captured, running
g1 · ptah-docs-stub · the generation queries readrun g1: cut_over, running
g2 · ptah-docs-stub-v2 · the new modelrun g2: backfilled, running
g1 · ptah-docs-stub · the generation queries readrun g1: cut_over, running
g2 · ptah-docs-stub-v2 · the new modelrun g2: caught_up, running
g1 · ptah-docs-stub · the generation queries readrun g1: cut_over, running
g2 · ptah-docs-stub-v2 · the new modelrun g2: indexed, running
g1 · ptah-docs-stub · the generation queries readrun g1: cut_over, running
g2 · ptah-docs-stub-v2 · the new modelrun g2: verified, running
g1 · ptah-docs-stub · the generation queries readrun g1: cut_over, running
g2 · ptah-docs-stub-v2 · the new modelrun g2: cut_over, running
$ ptah inference plan --spec /work/spec-g2.yaml --db-url 'postgres://ptah:ptah@postgres:5432/ptah?sslmode=disable'
generation 4f8a8e77ec3904675efb9678ec1b3de138a135b21d267621d93decf47e581d85
  source.table = docs (configured: the specification)
  model.identifier = ptah-docs-stub-v2 (configured: the specification)
  model.revision = 1 (configured: the specification)
  provider.credential =  (configured: the specification, as a reference rather than a value)
  generation.reproducibility = full (inferred: the specification pins an immutable model revision)
  source.estimated_rows = 3 (measured)
  run.consistency_mode = outbox (configured: the migration specification)
  target.exists = false (measured)
  target.capability.vector_type = true (measured)
  target.capability.vector_index = true (measured)
  [prepare] create the vector column and metadata for generation 4f8a8e77ec3904675efb9678ec1b3de138a135b21d267621d93decf47e581d85
  [capture] install the outbox mechanism and record its starting position
  [backfill] embed 3 in-scope source rows
  [catch-up] process every source change recorded after the snapshot began
  [index] build the vector index and wait for it to be valid
  [verify] run every verification layer against the exact target generation
  [cutover] point queries at generation 4f8a8e77ec3904675efb9678ec1b3de138a135b21d267621d93decf47e581d85, which nothing currently reads

What is not established:
  - generation.reproducibility = full (inferred: the specification pins an immutable model revision)

What leaves the database:
  - ptah-docs-stub-v2 at http://embeddings:8080/v1, declared local
  - the text of title, body
  - for 3 rows

Consistency mode: outbox
  - the outbox event and the source change are one transaction, so a change that committed has an event
$ ptah inference prepare --spec /work/spec-g2.yaml --db-url 'postgres://ptah:ptah@postgres:5432/ptah?sslmode=disable' --run-id g2
prepared run g2 for generation 4f8a8e77ec39
  - snapshot boundary: 788
$ ptah inference backfill --spec /work/spec-g2.yaml --db-url 'postgres://ptah:ptah@postgres:5432/ptah?sslmode=disable' --run-id g2 --batch-rows 10
backfill finished: 3 scanned, 3 embedded, 0 skipped
$ ptah inference catchup --spec /work/spec-g2.yaml --db-url 'postgres://ptah:ptah@postgres:5432/ptah?sslmode=disable' --run-id g2 --batch-rows 10
caught up to transaction 796: 0 changed rows, 0 tombstoned
  - the outbox keeps events this run has processed: floor 773 for public.docs is held by run g1 (generation b7cc9eb5c7d2ee13ad46ba3d5da8a479ec57b128d771e4151fab5bf6d1da0866)
$ ptah inference index --spec /work/spec-g2.yaml --db-url 'postgres://ptah:ptah@postgres:5432/ptah?sslmode=disable' --run-id g2
generation 4f8a8e77ec39 has a valid index
$ ptah inference verify --spec /work/spec-g2.yaml --db-url 'postgres://ptah:ptah@postgres:5432/ptah?sslmode=disable' --run-id g2
generation 4f8a8e77ec3904675efb9678ec1b3de138a135b21d267621d93decf47e581d85: 3 source rows, 3 target rows
  - every deterministic layer passed
  - not measured: the stored vectors were not read back, so their dimension was checked and their values were not; the target refuses a non-finite component on write, so nothing here could have carried one
$ ptah inference cutover --spec /work/spec-g2.yaml --db-url 'postgres://ptah:ptah@postgres:5432/ptah?sslmode=disable' --run-id g2
plan 949aa4c67b4b
cutover refused:
  - this policy requires an approval and none was given
error: cutover refused

$ ptah inference cutover --spec /work/spec-g2.yaml --db-url 'postgres://ptah:ptah@postgres:5432/ptah?sslmode=disable' --run-id g2 --approve 949aa4c67b4b --approver 'site check'
queries now read generation 4f8a8e77ec3904675efb9678ec1b3de138a135b21d267621d93decf47e581d85 (plan 949aa4c67b4b)
  - no stabilization window was asked for, so nothing is keeping the previous generation current and there is no rollback to it

Ptah builds the new generation beside the one queries read, calls your embeddings endpoint, verifies, and switches queries over when an approval names the plan. It never produces a vector itself, and plan tells you what leaves the database before anything runs.

Schema as Code

Works with the schemas you already have.

Define the schema you want; review the changes needed to get there. Every source parses into the same internal schema before planning — files, Go annotations, a live database, or any program that prints one, an ORM included.

schema.sqlschema.yamlschema.hclschema.dbmlmodels/user.go
CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL,
  created_at TEXT
);
tables:
  users:
    columns:
      id:
        type: INTEGER
        primary: true
      email:
        type: TEXT
        not_null: true
      created_at:
        type: TEXT
table "users" {
  column "id" {
    type = integer
    null = true
  }

  column "email" {
    type = text
    null = false
  }

  column "created_at" {
    type = text
    null = true
  }

  primary_key {
    columns = [column.id]
  }
}
Table users {
  id integer [pk]
  email text [not null]
  created_at text
}
package models

//ptah:schema:table name="users"
type User struct {
	//ptah:schema:field name="id" type="INTEGER" primary="true"
	ID int64

	//ptah:schema:field name="email" type="TEXT" not_null="true"
	Email string

	//ptah:schema:field name="created_at" type="TEXT"
	CreatedAt *string
}
same plan, whatever the sourceexit 0exit 0exit 0exit 0exit 0
$ ptah schema apply --schema-file schema.sql \
    --db-url sqlite://app.db --dry-run
Planned schema changes:
ALTER TABLE "users" ADD COLUMN "created_at" TEXT;
$ ptah schema apply --schema-file schema.yaml \
    --db-url sqlite://app.db --dry-run
Planned schema changes:
ALTER TABLE "users" ADD COLUMN "created_at" TEXT;
$ ptah schema apply --schema-file schema.hcl \
    --db-url sqlite://app.db --dry-run
Planned schema changes:
ALTER TABLE "users" ADD COLUMN "created_at" TEXT;
$ ptah schema apply --schema-file schema.dbml \
    --db-url sqlite://app.db --dry-run
Planned schema changes:
ALTER TABLE "users" ADD COLUMN "created_at" TEXT;
$ ptah schema apply --root-dir ./models \
    --db-url sqlite://app.db --dry-run
Planned schema changes:
ALTER TABLE "users" ADD COLUMN "created_at" TEXT;
Or let the ORM be the schema

Any program that prints the complete schema as SQL, HCL or YAML to stdout is a source. Ptah runs it without a shell, bounds it, and refuses empty output.

  • GORM
  • SQLAlchemy
  • Django
  • TypeORM
  • Sequelize
  • Hibernate
  • your script

a committed, version-pinned recipe in the repository · the others print SQL DDL and can use the same contract

$ ptah schema drift \
    --schema-cmd ./scripts/export-schema \
    --db-url sqlite://app.db
No schema drift detected.
Reference data as code

Lookup rows, declared next to the tables.

Ptah diffs the declared rows against the live table and writes a reversible data migration. Drift checks and direct plans read the same rows. A data migration that updates or deletes rows needs --allow-destructive.

  1. //ptah:schema:data · countries.yaml
  2. diff by key against the live table
  3. INSERT · UPDATE · DELETE
  4. .up.sql / .down.sql · ptah.sum
Database access as code

Roles, grants and row-level policies, declared with the tables.

ptah schema security reads the live database and flags a table granted to a role with no row-level security. ptah viz --security marks the same finding on the declared schema. Gate a pull request on it with --fail-on any.

  1. GRANT … TO role
  2. ptah schema security
  3. PRV01 · no RLS on a granted table
  4. declare a policy
Schema export

One schema, exported to whatever needs it.

Seven targets. Go annotations reach all seven; a schema file reaches every one but HCL. Contracts for machines, documents for people, two more schema formats. Nothing connects to a database, and every export is a candidate to review.

$ ptah schema export --to openapi-v3 --root-dir ./models
$ ptah schema export --to graphql --root-dir ./models \
    --graphql-operations list,by-id
$ ptah schema export --to protobuf --root-dir ./models \
    --proto-package shop.v1 --out shop.proto
$ ptah schema export --to markdown --root-dir ./models
$ ptah schema export --to html --root-dir ./models --out schema.html
$ ptah schema export --to dbml --root-dir ./models
$ ptah schema export --to hcl --root-dir ./models --out schema.hcl
openapi: 3.0.3
info:
  title: Ptah Exported Schema
  version: 1.0.0
servers:
  - url: /
paths: {}
components:
  schemas:
    products:
      type: object
      required:
        - id
        - name
        - price
        - status
      properties:
        id:
          type: integer
          format: int32
        name:
          type: string
          maxLength: 255
        price:
          type: number
        status:
          type: string
          enum:
            - active
            - inactive
# Code generated by ptah; DO NOT EDIT.

# Operation shapes below were generated on request. They are type declarations
# only: Ptah generates no resolvers, no data access, no authorization or tenant
# isolation, and no filtering, ordering, pagination, or transaction behavior.

enum ProductStatus {
  active
  inactive
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Query {
  products(first: Int, after: String): ProductConnection
  product(id: ID!): Product
}

type Product {
  id: ID!
  name: String!
  price: Float!
  status: ProductStatus!
}

type ProductEdge {
  node: Product!
  cursor: String!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
}
Exported Protobuf schema to /tmp/app/shop.proto
Exported 1 message(s), 4 field(s), 1 enum(s)
bootstrapped new compatibility history
2 export warning(s) reported

$ cat shop.proto
edition = "2023";

package shop.v1;

message Product {
  int32 id = 1;
  string name = 2;
  string price = 3;
  ProductStatus status = 4;
}

enum ProductStatus {
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_ACTIVE = 1;
  PRODUCT_STATUS_INACTIVE = 2;
}

// Code generated by ptah schema export --to protobuf; DO NOT EDIT.
// ptah:protobuf-export-version=2
// ptah:content-sha256=4b1c2203313b7090dd40822f26eda0ee8d0b1df0ffb61c3691aa34d06de3c81b
# Schema reference

- [products](#products)

## products

| Column | Type | Null | Default | Key | Comment |
| --- | --- | --- | --- | --- | --- |
| id | SERIAL | no | — | PK | — |
| name | VARCHAR(255) | no | — | — | — |
| price | DECIMAL(10,2) | no | — | — | — |
| status | product_status | no | — | — | — |

## Enums

- `product_status` — active, inactive
Exported schema documentation to /tmp/app/schema.html
Found 1 table(s), 4 field(s), 1 enum(s)
Enum "product_status" {
  "active"
  "inactive"
}

Table "products" {
  "id" SERIAL [pk, not null]
  "name" VARCHAR(255) [not null]
  "price" DECIMAL(10,2) [not null]
  "status" product_status [not null]
}
Exported HCL schema to /tmp/app/schema.hcl
Found 1 table(s), 4 field(s), 1 enum(s)

$ cat schema.hcl
// Code generated by ptah; DO NOT EDIT.

enum "product_status" {
  values = ["active", "inactive"]
}

table "products" {
  column "id" {
    type = SERIAL
  }
  column "name" {
    type = VARCHAR(255)
  }
  column "price" {
    type = DECIMAL(10,2)
  }
  column "status" {
    type = enum.product_status
  }
  primary_key {
    columns = [column.id]
  }
}

api_name, api_type and api_expose shape the published contract without touching the migration engine — a DECIMAL can ship as a string, and a column can be left out of the contract. Protobuf field numbers persist in the file you commit.

Agentic development

Two ways to put a model on the project. One set of tools. Never the database.

Pick who owns the model connection. Everything downstream is the same Ptah: eleven tools with a workspace, every write previewed and checked against a digest, an audit line for each decision.

ptah assistPtah owns the model connection, through a profile you configure: the OpenAI-compatible or the Anthropic API.
youterminal
ptah assista model on your machine, a gateway or a provider
your projectschema · migrations · a dev database
ptah mcpThe client owns the model. Claude, Cursor, VS Code, Zed, or any MCP client that runs a local server over stdio.
youin the client
your MCP clientClaude · Cursor · VS Code · Zed
your projectschema · migrations · a dev database
  • read → preview → approve → applyThe approval covers one patch, named by its digest. If the files changed after the preview, the apply refuses.
  • Allow once · for this session · NoYou answer in the terminal or in the client. Repository policy can only remove authority.
  • Never writes to the databaseNo tool runs SQL, changes a database or applies a migration, and no setting adds one. ptah migrations up stays a person’s step.
  • Audit and byte countWith a workspace, every permission decision goes to .ptah/agent-audit.jsonl. In ptah assist, each answer ends with how many bytes of project content reached the provider.
ptah assist · the approval momentrecorded live
┃ Ptah is asking for artifact.write:schema.
┃
┃ apply a 1-file patch to the schema artifact
┃   artifact: schema
┃   directory: /home/developer/projects/ptah-assist-lab/workspace/schema
┃   base digest: sha256:c2faa21a30324714af7e7de289200af7f5a84908223ba1cba094b4a9
┃   result digest: sha256:fcb3f414e6936591b9510583b77fdead27f6419602161183a89a00
┃   update: schema.yaml
┃
┃ This approval covers exactly sha256:6e2a256e07315164caa524dd82d73a922ede320cd1
┃ > Allow once
┃   Allow for this session
┃   No

↑ up • ↓ down • / filter • enter submit
ptah assist · the model can refuse toorecorded live
> now write the migration for that column into the migrations directory in Ptah's native format, and
 call apply_patch for it. be brief

  Held — not written. Two blockers, both mine, not yours:

  • Format unknown: no tool result in this session has shown me Ptah's native migration file
  format, and I won't invent one.
  • No preview to apply:  apply_patch  needs a preview token/digest from a fresh  preview_patch
  against the current migrations artifact (last listed: 0 files, digest  sha256:70f6b4… ). I have
  none, and I can't claim a write or a verification pass without them.

  If the migration tool surface is enabled here, I'll: preview a migration adding  tasks.active
  (matching the schema change already applied), show you the diff, then apply it with its token.

-- AcmeQuant/Qwen3.8-27B via router, 1 turn(s), 0 tool call(s), answer
-- No Ptah tool answered, so nothing above was checked against this project.

Fully open source. Zero telemetry. Runs where the network does not.

Built for data that has to stay where it is: regulated, sovereign, classified. Every component can be self-hosted, and none of them phones home.

  • MITFully open source

    The CLI, ptah-compat, ptah-ls, the Kubernetes Operator, the GitHub Action and this site. Four repositories, one license, and no paid tier holds back a safety check.

  • PrivateZero telemetry

    Ptah connects only to addresses you give it: your database, the embedding endpoint, the model provider for ptah assist, and the registry you push to or pull from. No usage pings, no update checks, no account.

  • YoursSovereign by default

    Schemas and migration directories as OCI artifacts in your own registry: ECR, GAR, Harbor, GHCR, Docker Hub or a plain registry:2. The Operator runs in your cluster. Credentials are references, never values, so a spec is a file you can commit.

  • Air-gappedNo network required

    Release archives hold ptah, ptah-compat and ptah-ls, built without CGO. Check an archive against checksums.txt with no network. For inference, --release accepts an oci-layout directory you copied across on disk.

Proof, not a promise

Before an inference run touches a provider, plan prints exactly what leaves: which model, which endpoint and its declared class, which columns, how many rows. It is the same list a data-protection review asks for.

$ ptah inference planprinted before anything runs
What leaves the database:
  - ptah-docs-stub-v2 at http://embeddings:8080/v1, declared local
  - the text of title, body
  - for 3 rows

Consistency mode: outbox
  - the outbox event and the source change are one transaction, so a change that committed has an event
GitOps for databases

Reconcile a database on a schema published as an OCI artifact.

The Ptah Operator pins the tag to a digest and uses only that digest. It verifies the artifact, observes the database and publishes a plan. By default it applies the plan only after an approval that names it, then observes the database again instead of trusting the job's exit code. Destructive plans are off by default; enabling them still binds the approval to the exact plan bytes.

PtahSchema · reconcile loopoperator.ptah.run
RECONCILEevery interval · on changeafter apply01resolve tag → digest02verify artifact03observe database04publish plan05approve06apply07observe againSTART
  1. resolve tag → digest
  2. verify artifact
  3. observe database
  4. publish plan
  5. approve
  6. apply
  7. observe again
05
destructive plans are off by default; enabling them binds the approval to the exact plan bytes
07
the loop trusts what it observes in the database, not the apply job's exit code
Databases

Know what works on your database.

Ten engines. Pick yours to see what Ptah manages there, the limitation worth knowing, and which release lines are tested.

PostgreSQLPrimary target

Manage tables, views, functions, extensions, custom types, roles and row-level security.

Broadest object model. Enum, domain, composite and range types, triggers, sequences and materialized views are managed here too.
Explore PostgreSQL support →
Tested release lines
14 – 18Ptah-certified
13Legacy-tested
Probed against a live server in CI.
SQLiteEmbedded

Schema changes, including automatic table rebuilds for alterations SQLite cannot express as ALTER TABLE.

Rebuilds are planned, not hidden. A rebuild copies the table, and the plan labels it as a SQLite table rebuild.
Explore SQLite support →
Tested release lines
3Ptah-certified
Embedded library, tested in-process — no server involved.
MySQLSupported

Planning that respects MySQL DDL rules: indexes, constraints, foreign keys and the SQL MySQL actually accepts.

Not interchangeable with MariaDB. Separate dialect and capability set even though the planner family is shared.
Explore MySQL support →
Tested release lines
8.4 · 9.7 · 26.7Ptah-certified
Probed against a live server in CI.
MariaDBSupported

MariaDB-specific DDL, indexes and constraints, planned with its own dialect rules.

Own capability set. A MariaDB server is recognized by its own version string, even behind mysql://, and planned with the MariaDB preset, never MySQL’s.
Explore MariaDB support →
Tested release lines
10.11 · 11.4 · 11.8 · 12.3Ptah-certified
Probed against a live server in CI.
CockroachDBPostgreSQL wire protocol

Schema changes and row-level TTL policies, planned through the PostgreSQL path with CockroachDB rules.

Not PostgreSQL. Operations CockroachDB lacks are refused or re-spelled before SQL is emitted, never shipped as they are.
Explore CockroachDB support →
Tested release lines
25.4 · 26.2 · 26.3Ptah-certified
Probed against a live server in CI.
YugabyteDBPostgreSQL wire protocol

Schema changes, roles and row-level security through the PostgreSQL path.

Index operations differ. Concurrent index creation is supported; DROP INDEX CONCURRENTLY is excluded by the YugabyteDB preset.
Explore YugabyteDB support →
Tested release lines
2024.2 · 2025.2 · 2026.1Ptah-certified
Probed against a live server in CI.
SQL ServerSupported subset

T-SQL tables, filtered indexes and live catalog inspection.

A conservative subset of T-SQL. Changes outside the subset need a manual migration.
Explore SQL Server support →
Tested release lines
2019 · 2022 · 2025Ptah-certified
Probed against a live server in CI.
OracleSupported

Schema inspection, planning and versioned migrations.

Declarative convergence has known limits. Comparing a declared file with a live Oracle catalog does not converge yet, and migration runs are not serialized by an advisory lock.
Explore Oracle support →
Tested release lines
21 · 23Ptah-certified
Probed against a live server in CI.
ClickHouseAnalytical

MergeTree tables with engine settings and sorting keys, materialized views and TTL policies.

Capability-gated. No foreign keys or transactions to model; the plan works with what the engine offers.
Explore ClickHouse support →
Tested release lines
26.3 · 26.7 · 26.8 · 26.9Ptah-certified
24.10Legacy-tested
Probed against a live server in CI.
SpannerPostgreSQL interface

Schema workflows through the PostgreSQL interface, tested against the Spanner emulator.

Evidence boundary. Emulator-tested; managed-service compatibility is not certified.
Explore Spanner support →
Evidence boundary
emulatorTested in CI
managedNot certified
The emulator does not prove the managed service.

Check your server’s capabilities.

Reports the detected version, its support level and the capability profile Ptah will plan with.

$ ptah db capabilities --db-url "$DATABASE_URL"
How to read the output →

Compatibility checks, backed by CI.

Automated probes run on every push to master and nightly: 28 Ptah-certified release lines — the lines Ptah tests in CI and commits to within their probed capabilities. Embedded SQLite and the Spanner emulator are declared as such, not as live servers.

View support evidence →
Use Ptah in your workflow

ptah on the command line. ptah-ls in the editor for Go annotations. GitHub Action in CI, Kubernetes Operator in the cluster, Go packages at ptah.run in your own code.

Coming from Atlas?

ptah-compat accepts Atlas-shaped commands and runs Ptah behavior behind them. Independent clean-room implementation, not affiliated with Ariga. Atlas compatibility →