Skip to content

Tables

PostgreSQL tables are the fundamental data storage structures containing rows and columns.

PostgreSQL Specification

CREATE TABLE Syntax

CREATE [ [ GLOBAL | LOCAL ] { TEMPORARY | TEMP } | UNLOGGED ] TABLE [ IF NOT EXISTS ] table_name ( [
  { column_name data_type [ STORAGE { PLAIN | EXTERNAL | EXTENDED | MAIN | DEFAULT } ] [ COMPRESSION compression_method ] [ COLLATE collation ] [ column_constraint [ ... ] ]
    | table_constraint
    | LIKE source_table [ like_option ... ] }
    [, ... ]
] )
[ INHERITS ( parent_table [, ... ] ) ]
[ PARTITION BY { RANGE | LIST | HASH } ( { column_name | ( expression ) } [ COLLATE collation ] [ opclass ] [, ... ] ) ]
[ USING method ]
[ WITH ( storage_parameter [= value] [, ... ] ) | WITHOUT OIDS ]
[ ON COMMIT { PRESERVE ROWS | DELETE ROWS | DROP } ]
[ TABLESPACE tablespace_name ]

Reference: PostgreSQL 17 CREATE TABLE

ALTER TABLE Syntax

ALTER TABLE [ IF EXISTS ] [ ONLY ] name [ * ]
    action [, ... ]

-- Where action is one of:
ADD [ COLUMN ] [ IF NOT EXISTS ] column_name data_type [ COLLATE collation ] [ column_constraint [ ... ] ]
DROP [ COLUMN ] [ IF EXISTS ] column_name [ RESTRICT | CASCADE ]
ALTER [ COLUMN ] column_name [ SET DATA ] TYPE data_type [ COLLATE collation ] [ USING expression ]
ALTER [ COLUMN ] column_name SET DEFAULT expression
ALTER [ COLUMN ] column_name DROP DEFAULT
ALTER [ COLUMN ] column_name { SET | DROP } NOT NULL
-- ... and many more

Reference: PostgreSQL 17 ALTER TABLE

pgdelta Support

✅ Currently Supported

CREATE TABLE

  • Basic table creation with columns
  • Column data types with precision/scale
  • NOT NULL constraints
  • DEFAULT expressions
  • Generated columns (GENERATED ALWAYS AS ... STORED)
  • Table inheritance (INHERITS clause)
  • Storage parameters (WITH clause)
  • Sequence ownership (ALTER SEQUENCE ... OWNED BY)
CREATE TABLE "public"."users" (
  "id" serial PRIMARY KEY,
  "email" text NOT NULL,
  "created_at" timestamp DEFAULT now(),
  "full_name" text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED
);

DROP TABLE

  • Table deletion
  • Cascade behavior through dependency resolution
DROP TABLE "public"."users";

ALTER TABLE

  • ADD COLUMN with all supported column options
  • DROP COLUMN
  • ALTER COLUMN TYPE with USING expression
  • ALTER COLUMN SET/DROP DEFAULT
  • ALTER COLUMN SET/DROP NOT NULL
ALTER TABLE "public"."users" ADD COLUMN "phone" text;
ALTER TABLE "public"."users" DROP COLUMN "deprecated_field";
ALTER TABLE "public"."users" ALTER COLUMN "email" TYPE varchar(255);

❌ Not Yet Supported

CREATE TABLE Options

  • Column STORAGE settings (PLAIN, EXTERNAL, EXTENDED, MAIN)
  • Column COMPRESSION settings
  • Column COLLATE settings
  • LIKE clause for copying table structure
  • PARTITION BY clause (RANGE, LIST, HASH partitioning)
  • USING method for access method
  • TABLESPACE clause
  • Identity columns (GENERATED BY DEFAULT AS IDENTITY)

ALTER TABLE Operations

  • Table renaming (RENAME TO)
  • Schema changes (SET SCHEMA)
  • Owner changes (OWNER TO)
  • Storage parameter modifications
  • Constraint modifications (handled separately)

🚫 Intentionally Not Supported

Temporary Objects

  • TEMPORARY/TEMP tables (not persistent schema objects)
  • UNLOGGED tables (storage detail that changes automatically)

Conditional Operations

  • IF NOT EXISTS (pgdelta tracks existence)
  • IF EXISTS (pgdelta tracks existence)

Environment-Specific Features

  • TABLESPACE clause (file system layout)
  • ON COMMIT behavior (session-specific)
  • WITHOUT OIDS (deprecated PostgreSQL feature)

Usage Examples

Basic Table Creation

from pgdelta import extract_catalog, generate_sql

# Target schema with new table
target_sql = """
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT now()
);
"""

# Generate diff
changes = source_catalog.diff(target_catalog)
sql = generate_sql(changes[0])
# Result: CREATE TABLE "public"."users" (...)

Table with Generated Columns

target_sql = """
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    tax_rate DECIMAL(5,4) DEFAULT 0.0825,
    total_price DECIMAL(10,2) GENERATED ALWAYS AS (price * (1 + tax_rate)) STORED
);
"""

# pgdelta generates:
# CREATE TABLE "public"."products" (
#   "id" serial NOT NULL,
#   "name" text NOT NULL,
#   "price" numeric(10,2) NOT NULL,
#   "tax_rate" numeric(5,4) DEFAULT 0.0825,
#   "total_price" numeric(10,2) GENERATED ALWAYS AS (price * (1 + tax_rate)) STORED
# );

Table Inheritance

target_sql = """
CREATE TABLE base_log (
    id SERIAL PRIMARY KEY,
    timestamp TIMESTAMP DEFAULT now(),
    message TEXT
);

CREATE TABLE error_log (
    error_code INTEGER,
    stack_trace TEXT
) INHERITS (base_log);
"""

# pgdelta generates inheritance relationship

Column Modifications

# Add column
source_sql = "CREATE TABLE users (id SERIAL PRIMARY KEY);"
target_sql = "CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT);"

# Generate: ALTER TABLE "public"."users" ADD COLUMN "email" text;

# Change column type
source_sql = "CREATE TABLE users (id SERIAL, email TEXT);"
target_sql = "CREATE TABLE users (id SERIAL, email VARCHAR(255));"

# Generate: ALTER TABLE "public"."users" ALTER COLUMN "email" TYPE varchar(255);

Implementation Details

Table Model

@dataclass(frozen=True)
class CreateTable:
    stable_id: str                          # Format: "t:schema.table"
    namespace: str                          # Schema name
    relname: str                           # Table name
    columns: list[PgAttribute]             # Column definitions
    table_options: dict[str, Any] | None   # Storage parameters
    inherits_from: list[str] | None        # Parent tables

Column Model

@dataclass(frozen=True)
class PgAttribute:
    attname: str                # Column name
    type_name: str             # Data type
    attnotnull: bool           # NOT NULL constraint
    default_value: str | None  # DEFAULT expression
    is_generated: bool         # Generated column flag
    generated_expression: str | None  # Generation expression

    @property
    def formatted_type(self) -> str:
        """Format type with precision/scale."""
        # e.g., "varchar(255)", "numeric(10,2)"

SQL Generation

def generate_create_table_sql(change: CreateTable) -> str:
    """Generate CREATE TABLE SQL."""
    quoted_schema = f'"{change.namespace}"'
    quoted_table = f'"{change.relname}"'

    sql_parts = [f"CREATE TABLE {quoted_schema}.{quoted_table} ("]

    # Add columns
    column_defs = []
    for col in change.columns:
        col_def = f'  "{col.attname}" {col.formatted_type}'

        if col.is_generated:
            col_def += f" GENERATED ALWAYS AS ({col.generated_expression}) STORED"
            if col.attnotnull:
                col_def += " NOT NULL"
        else:
            if col.default_value:
                col_def += f" DEFAULT {col.default_value}"
            if col.attnotnull:
                col_def += " NOT NULL"

        column_defs.append(col_def)

    sql_parts.append("\n" + ",\n".join(column_defs) + "\n")
    sql_parts.append(")")

    # Add inheritance
    if change.inherits_from:
        inherits_tables = [f'"{table}"' for table in change.inherits_from]
        sql_parts.append(f" INHERITS ({', '.join(inherits_tables)})")

    return "".join(sql_parts) + ";"

Testing

Unit Tests

def test_create_table_basic():
    """Test basic table creation."""
    column = PgAttribute(
        attname="id",
        type_name="integer",
        attnotnull=True,
        default_value="nextval('users_id_seq'::regclass)"
    )

    change = CreateTable(
        stable_id="t:public.users",
        namespace="public",
        relname="users",
        columns=[column]
    )

    sql = generate_create_table_sql(change)
    assert "CREATE TABLE \"public\".\"users\"" in sql
    assert "\"id\" integer DEFAULT nextval('users_id_seq'::regclass) NOT NULL" in sql

Integration Tests

def test_table_roundtrip(postgres_session):
    """Test table creation roundtrip fidelity."""
    # Create table
    postgres_session.execute(text("""
        CREATE TABLE test_table (
            id SERIAL PRIMARY KEY,
            name TEXT NOT NULL DEFAULT 'unnamed',
            created_at TIMESTAMP DEFAULT now()
        )
    """))
    postgres_session.commit()

    # Extract catalog
    catalog = extract_catalog(postgres_session)

    # Find table
    table = next(t for t in catalog.tables if t.relname == "test_table")
    assert len(table.columns) == 3

    # Verify column properties
    id_col = next(c for c in table.columns if c.attname == "id")
    assert id_col.attnotnull
    assert "nextval" in id_col.default_value

ALTER TABLE Tests

def test_add_column():
    """Test ADD COLUMN generation."""
    source_sql = "CREATE TABLE users (id SERIAL PRIMARY KEY);"
    target_sql = "CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT);"

    changes = generate_changes(source_sql, target_sql)

    alter_change = next(c for c in changes if isinstance(c, AlterTable))
    assert alter_change.add_columns
    assert alter_change.add_columns[0].attname == "email"

    sql = generate_alter_table_sql(alter_change)
    assert "ALTER TABLE \"public\".\"users\" ADD COLUMN \"email\" text;" in sql

Error Handling

Common Errors

# Type conversion errors
try:
    sql = "ALTER TABLE users ALTER COLUMN id TYPE text;"
    # May fail if data cannot be converted
except Exception as e:
    # pgdelta can generate USING expression for safe conversion
    safe_sql = "ALTER TABLE users ALTER COLUMN id TYPE text USING id::text;"

Data Type Validation

def validate_type_compatibility(old_type: str, new_type: str) -> bool:
    """Check if type conversion is safe."""
    safe_conversions = {
        ("integer", "bigint"),
        ("text", "varchar"),
        ("varchar", "text"),
        ("numeric", "text"),
    }

    return (old_type, new_type) in safe_conversions

Advanced Features

Generated Columns

# Generated column support
target_sql = """
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    subtotal DECIMAL(10,2) NOT NULL,
    tax_rate DECIMAL(5,4) DEFAULT 0.0825,
    total DECIMAL(10,2) GENERATED ALWAYS AS (subtotal * (1 + tax_rate)) STORED
);
"""

# pgdelta handles generated column syntax correctly

Table Inheritance

# Inheritance support
target_sql = """
CREATE TABLE vehicles (
    id SERIAL PRIMARY KEY,
    make TEXT,
    model TEXT
);

CREATE TABLE cars (
    doors INTEGER
) INHERITS (vehicles);
"""

# pgdelta tracks inheritance relationships

Storage Parameters

# Storage parameter support
target_sql = """
CREATE TABLE large_table (
    id BIGSERIAL PRIMARY KEY,
    data TEXT
) WITH (
    fillfactor = 70,
    autovacuum_vacuum_scale_factor = 0.1
);
"""

# pgdelta preserves storage parameters

Future Enhancements

Planned Features (v0.2.0)

Partitioning Support

CREATE TABLE measurements (
    id SERIAL,
    measurement_date DATE,
    value NUMERIC
) PARTITION BY RANGE (measurement_date);

CREATE TABLE measurements_2024 PARTITION OF measurements
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');

Identity Columns

CREATE TABLE products (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL
);

Advanced ALTER TABLE

-- Table renaming
ALTER TABLE old_name RENAME TO new_name;

-- Schema changes
ALTER TABLE public.table1 SET SCHEMA app;

-- Storage parameter changes
ALTER TABLE table1 SET (fillfactor = 80);

Implementation Challenges

Partitioning

  • Complex dependency relationships
  • Partition constraint management
  • Partition pruning considerations

Identity Columns

  • Sequence management
  • Identity property preservation
  • Conversion between serial and identity

Best Practices

Column Naming

# Good column names
good_names = [
    "id", "user_id", "created_at",
    "first_name", "last_name", "email",
    "is_active", "status", "version"
]

# Avoid reserved words
avoid_names = [
    "order", "group", "user", "table",
    "select", "where", "from", "join"
]

Data Type Selection

# Recommended type mappings
type_mappings = {
    "id": "SERIAL or BIGSERIAL",
    "text": "TEXT (unlimited) or VARCHAR(n)",
    "money": "DECIMAL(10,2) not MONEY",
    "boolean": "BOOLEAN not CHAR(1)",
    "timestamp": "TIMESTAMP WITH TIME ZONE",
    "uuid": "UUID with uuid-ossp extension",
}

Default Values

# Good default patterns
good_defaults = {
    "created_at": "now()",
    "updated_at": "now()",
    "is_active": "true",
    "version": "1",
    "status": "'pending'",
}

# Avoid non-deterministic defaults in some cases
avoid_defaults = {
    "random()": "Use at application level",
    "current_user": "May vary by connection",
}