Skip to content

PostgreSQL

DBWarden treats PostgreSQL as a first-class backend: every natively supported feature is reverse-engineered, diffed, and emitted as correct DDL.

First-Class Features

"First-class" means the round-trip is verified: reverse-engineer a live database with generate-models, feed the output back into make-migrations, and get zero diff.

# Step 1: reverse-engineer your live PostgreSQL database
$ dbwarden generate-models -d primary --tables users,orders,items

# Step 2: feed the generated models back in, zero diff
$ dbwarden make-migrations
# → "No changes detected"  (output is empty; your models match the DB exactly)

The following PostgreSQL features are fully supported in this round-trip:

Category Features
Identity Columns GENERATED ALWAYS AS IDENTITY, GENERATED BY DEFAULT AS IDENTITY, sequence options (START WITH, INCREMENT BY, MINVALUE, MAXVALUE)
Collation Per-column COLLATE via pg.field(collation=...)
Storage Per-column STORAGE setting (PLAIN, MAIN, EXTERNAL, EXTENDED) via pg.field(storage=...)
Compression Per-column COMPRESSION (pglz, zstd) via pg.field(compression=...) (PG 14+)
Generated Columns GENERATED ALWAYS AS (...) STORED via pg.field(generated=...)
Table Fillfactor WITH (fillfactor = N) via pg_fillfactor
Tablespace SET TABLESPACE via pg_tablespace
Unlogged Tables UNLOGGED via pg_unlogged
Partitioning PARTITION BY RANGE / LIST / HASH (columns) via pg_partition
Table Inheritance INHERITS (parent) via pg_inherits
EXCLUDE Constraints EXCLUDE USING gist (...) via pg_excludes
Check Constraints CHECK (...) with NO INHERIT support via pg_checks
Unique Constraints Full option diff: NULLS NOT DISTINCT, DEFERRABLE INITIALLY DEFERRED, INCLUDE via pg_uniques
Deferrable FK DEFERRABLE INITIALLY DEFERRED with ON DELETE / ON UPDATE options
Index Options USING, WHERE, INCLUDE, WITH, TABLESPACE, NULLS NOT DISTINCT, CONCURRENTLY, column sorting
Enum Types CREATE TYPE ... AS ENUM, ALTER TYPE ... ADD VALUE ... AFTER ...
Comments Table and column COMMENT ON
Type Normalization SERIALinteger + autoincrement, TIMESTAMPTZ, NUMERIC(p,s), VARCHAR(n), DOUBLE PRECISION, REAL, JSONB, UUID, ARRAY, ENUM, TSTZRANGE
Auto-increment Lifecycle Toggle autoincrement on integer PKs via autoincrement field: generates CREATE SEQUENCE / DROP SEQUENCE + SET DEFAULT nextval

Declaring Metadata

PostgreSQL metadata is declared in a class Meta inner class on the model. This is the only supported surface: mapped_column(info=...) raises DBWardenConfigError.

Table-Level Meta

Inherit from PGTableMeta on your class Meta:

from sqlalchemy import Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from dbwarden.databases.pgsql import PGTableMeta

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)

    class Meta(PGTableMeta):
        pg_fillfactor = 80
        pg_tablespace = "fastspace"
        pg_inherits = "base_entity"
        pg_excludes = [
            {"name": "excl_room_booking", "expression": "USING gist (room_id WITH =, during WITH &&)"},
        ]

PGTableMeta inherits from TableMeta, which provides common attributes shared across all backends:

Attribute Type SQL
comment str COMMENT ON TABLE t IS '...'
indexes list[dict] CREATE INDEX ...
checks list[dict] ALTER TABLE t ADD CONSTRAINT ... CHECK (...)
uniques list[dict] ALTER TABLE t ADD CONSTRAINT ... UNIQUE (...)

PostgreSQL-specific PGTableMeta attributes:

Attribute Type SQL
pg_fillfactor int ALTER TABLE t SET (fillfactor = N)
pg_tablespace str ALTER TABLE t SET TABLESPACE name
pg_unlogged bool CREATE UNLOGGED TABLE ... / ALTER TABLE t SET UNLOGGED
pg_partition dict PARTITION BY RANGE / LIST / HASH (columns)
pg_inherits str \| list[str] ALTER TABLE t INHERIT parent
pg_excludes list[dict] ALTER TABLE t ADD CONSTRAINT ... EXCLUDE USING ...
pg_indexes list[PgIndexSpec] CREATE INDEX ... (with USING, WHERE, INCLUDE, NULLS NOT DISTINCT, column sorting)
pg_checks list[dict] ALTER TABLE t ADD CONSTRAINT ... CHECK (...) (with NO INHERIT)
pg_uniques list[dict] ALTER TABLE t ADD CONSTRAINT ... UNIQUE (...) (with DEFERRABLE, NULLS NOT DISTINCT, INCLUDE)

The pg_indexes list uses PgIndexSpec objects (from dbwarden.databases.pgsql or dbwarden):

from dbwarden.databases.pgsql import PgIndexSpec

class Meta(PGTableMeta):
    pg_indexes = [
        PgIndexSpec("ix_users_email", ["email"],
            unique=True, using="gin"),
    ]

Column-Level Meta

Use PGColumnMeta inner classes for per-column metadata. The inner class must be named after the column. Use pg = pg.field(...) to set column-level options:

from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from dbwarden.databases.pgsql import PGTableMeta, PGColumnMeta, pg

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    email: Mapped[str] = mapped_column(String(255))
    bio: Mapped[str] = mapped_column(Text)

    class Meta(PGTableMeta):
        class id(PGColumnMeta):
            pg = pg.field(identity="always", identity_start=100, identity_increment=1)

        class bio(PGColumnMeta):
            pg = pg.field(storage="EXTENDED", compression="pglz", collation="en_US.UTF-8")

PGColumnMeta includes common column attributes shared across all backends:

Attribute Type SQL
comment str COMMENT ON COLUMN t.c IS '...'
public bool Controls field visibility in schemap auto-schema
pg PgFieldSpec PostgreSQL-specific column options (see table below)

PostgreSQL-specific PgFieldSpec fields (set via pg.field(...)):

Keyword Type SQL
collation str ALTER COLUMN c TYPE t COLLATE "name"
storage str ALTER COLUMN c SET STORAGE {PLAIN\|MAIN\|EXTERNAL\|EXTENDED}
compression str ALTER COLUMN c SET COMPRESSION {pglz\|zstd} (PG 14+)
generated str GENERATED ALWAYS AS (expr) STORED
identity str ADD GENERATED {ALWAYS\|BY DEFAULT} AS IDENTITY
identity_start int Sequence START WITH
identity_increment int Sequence INCREMENT BY
identity_min int Sequence MINVALUE
identity_max int Sequence MAXVALUE

Foreign Key Options

Foreign key options (ondelete, onupdate, deferrable) are captured from the database by generate-models and emitted in the ForeignKey constructor:

from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column

class OrderItem(Base):
    __tablename__ = "order_items"

    order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE", onupdate="CASCADE", deferrable=True), nullable=False)

DDL Behavior

Transactional DDL

PostgreSQL DDL is transactional. If a migration file contains multiple statements and one fails, all prior DDL in that file is rolled back. This makes PostgreSQL the safest backend for automated migration runs.

Index Creation

DBWarden defaults to CREATE INDEX CONCURRENTLY to avoid table locking. Pass --no-concurrent when the migration must run inside a transaction block (PostgreSQL requires CONCURRENTLY outside a transaction).

Column Type Changes

Emits ALTER TABLE t ALTER COLUMN c TYPE newtype with a commented-out -- USING col::newtype line. Pass --postgres-auto-using to emit an active USING clause. Without the flag, uncomment and verify the USING expression before running the migration against production.

Safe Type Change

The --safe-type-change flag generates a multi-step strategy: 1. Add a temporary column with the new type 2. Emit a -- comment with an UPDATE statement template 3. Emit a verification comment 4. After manual verification, drop the old column and rename the temporary column

Generated Columns

Adding a generated column via ALTER TABLE is not supported by PostgreSQL. ALTER COLUMN column_name ADD GENERATED AS (expr) STORED is not valid DDL. DBWarden emits a comment placeholder noting this limitation. Dropping the generation expression (ALTER COLUMN c DROP EXPRESSION) produces real DDL.

Auto-increment Lifecycle

DBWarden supports toggling auto-increment on integer primary key columns. The autoincrement field in your model controls whether a column uses SERIAL-style sequence auto-increment or is a plain integer:

class User(Base):
    __tablename__ = "users"

    # Autoincrement enabled (SERIAL): same as default behavior
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    class Meta(PGTableMeta):
        id = ColumnMeta(autoincrement=True)

To explicitly disable auto-increment on a PK column:

class User(Base):
    __tablename__ = "users"

    # Plain integer PK: no sequence, no auto-increment
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)

What happens when autoincrement changes:

Change Generated SQL
Adding autoincrement CREATE SEQUENCE users_id_seq + ALTER COLUMN id SET DEFAULT nextval('users_id_seq') + ALTER SEQUENCE users_id_seq OWNED BY users.id
Removing autoincrement ALTER COLUMN id DROP DEFAULT + DROP SEQUENCE IF EXISTS users_id_seq

The rollback SQL is the symmetric inverse: if an autoincrement addition is rolled back, the sequence is dropped and the default is removed.

Detection from live databases:

When reverse-engineering a live PostgreSQL database, DBWarden detects autoincrement by: 1. SERIAL/BIGSERIAL column types: type string contains serial 2. SQLAlchemy's .autoincrement attribute: set by the PG dialect for SERIAL columns 3. nextval(...) default patterns: PG SERIAL columns have DEFAULT nextval('table_col_seq'::regclass)

Sequence lifecycle:

Sequences created by SERIAL auto-increment follow the naming convention table_column_seq. When autoincrement is removed: - The column default (nextval(...)) is dropped - The sequence is dropped via DROP SEQUENCE IF EXISTS - The column type remains INTEGER; no data is lost

When autoincrement is added to an existing integer column: - A new sequence is created starting at 1 - The column default is set to nextval('table_column_seq') - The sequence is owned by the column for proper cleanup on table drop

Type mapping behavior:

The type mapping in _map_sqlalchemy_type_to_backend promotes INTEGER PRIMARY KEY to SERIAL only when autoincrement is not explicitly False:

Condition Resulting Type
autoincrement=True (default) SERIAL / BIGSERIAL
autoincrement=False INTEGER / BIGINT
autoincrement=None (unspecified) SERIAL / BIGSERIAL (backward compatible)

Non-PostgreSQL backends:

SQLite, MySQL, and ClickHouse do not support sequence-based auto-increment toggling via ALTER. The alter_column_autoincrement operation emits a comment explaining the limitation on these backends.

Snapshot Format

The snapshot JSON captures all PostgreSQL-specific metadata. Key sections:

Column Extras

{
  "name": "bio",
  "type": "text",
  "pg_column": {
    "collation": "en_US.UTF-8",
    "storage": "EXTENDED",
    "compression": "pglz",
    "generated": null,
    "identity": "always",
    "identity_start": 1,
    "identity_increment": 1
  }
}

Table Extras

{
  "pg_table": {
    "pg_fillfactor": 80,
    "pg_tablespace": "fastspace",
    "pg_unlogged": false,
    "pg_inherits": "base_entity",
    "pg_partition": {
      "strategy": "RANGE",
      "columns": ["created_at"]
    },
    "pg_excludes": [
      {"name": "excl_room_booking", "expression": "EXCLUDE USING gist (room_id WITH =, during WITH &&)"}
    ]
  }
}

Foreign Key Extras

{
  "type": "foreign_key",
  "table": "order_items",
  "columns": ["order_id"],
  "referenced_table": "orders",
  "referenced_columns": ["id"],
  "on_delete": "CASCADE",
  "on_update": "CASCADE",
  "deferrable": true
}

Constraint Diffing

Constraints (unique, check, foreign key, exclude) are compared by full attribute content. Any difference in signature: columns, expression, options, produces a DROP + ADD. Constraint name changes are detected as a new constraint (the old name is dropped, the new name is added).

FK comparison uses a 6-tuple signature: (columns, ref_table, ref_columns, on_delete, on_update, deferrable).

Reverse Engineering

generate-models queries pg_class, pg_attribute, pg_constraint, pg_inherits, pg_tablespace, pg_partitioned_table, and pg_collation to reverse-engineer all PostgreSQL metadata. The emitted model uses class Meta with PGTableMeta and PGColumnMeta inner classes.

$ dbwarden generate-models -d primary

Generated output for a table with identity, storage, compression, collation, and fillfactor:

from dbwarden.databases.pgsql import pg

class User(Base):
    __tablename__ = "users"

    id:    Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True)
    bio:   Mapped[str | None] = mapped_column(Text, nullable=True)

    class Meta(PGTableMeta):
        comment = "Core user accounts"
        pg_fillfactor = 80

        class id(PGColumnMeta):
            pg = pg.field(identity="always", identity_start=100, identity_increment=1)

        class bio(PGColumnMeta):
            pg = pg.field(storage="EXTENDED", compression="pglz", collation="en_US.UTF-8")

For a partitioned table, generate-models emits pg_partition:

class Event(Base):
    __tablename__ = "events"

    id: Mapped[int] = mapped_column(primary_key=True)
    created_at: Mapped[datetime] = mapped_column(DateTime)

    class Meta(PGTableMeta):
        pg_partition = {"strategy": "RANGE", "columns": ["created_at"]}

Unlogged tables, NO INHERIT check constraints, deferred unique constraints, and ALTER TYPE ... ADD VALUE for enums are all detected and emitted automatically.

Safety Classification

DBWarden classifies migration changes using the Safety enum:

from dbwarden.engine.safety import Safety

assert Safety.SAFE == "SAFE"
assert Safety.INFO == "INFO"
assert Safety.WARN == "WARN"
assert Safety.CRITICAL == "CRITICAL"
Change Type Severity Flag Required
Add column INFO None
Drop column WARNING --force
Change column type (safe) INFO None
Change column type (warn) WARNING --force
Change column type (critical) WARNING --force
Change column comment INFO None
Change PG column meta WARNING --force
Change fillfactor INFO None
Change tablespace WARNING --force
Change inheritance WARNING --force
Change exclude constraints WARNING --force
Change table comment INFO None
Change object type WARNING --force
Add / drop index INFO / WARNING --force
Add / drop FK INFO / WARNING --force